Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Authorisation #93

Merged
merged 14 commits into from
Dec 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,4 @@ __pycache__/
/scratch/
/packages-dist/
/.coverage
/users.db
10 changes: 5 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ update-lockfiles:

.PHONY: format
format:
ruff check --fix-only $(path)
ruff format $(path)
ruff check --fix-only $(path) demo
ruff format $(path) demo

.PHONY: lint
lint:
ruff check $(path)
ruff format --check $(path)
ruff check $(path) demo
ruff format --check $(path) demo

.PHONY: typecheck
typecheck:
Expand All @@ -40,7 +40,7 @@ testcov: test

.PHONY: dev
dev:
uvicorn demo:app --reload
uvicorn demo:app --reload --reload-dir .

.PHONY: all
all: testcov lint
4 changes: 4 additions & 0 deletions demo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,17 @@
from fastui.dev import dev_fastapi_app
from httpx import AsyncClient

from .auth import router as auth_router
from .components_list import router as components_router
from .db import create_db
from .forms import router as forms_router
from .main import router as main_router
from .tables import router as table_router


@asynccontextmanager
async def lifespan(app_: FastAPI):
await create_db()
async with AsyncClient() as client:
app_.state.httpx_client = client
yield
Expand All @@ -32,6 +35,7 @@ async def lifespan(app_: FastAPI):
app.include_router(components_router, prefix='/api/components')
app.include_router(table_router, prefix='/api/table')
app.include_router(forms_router, prefix='/api/forms')
app.include_router(auth_router, prefix='/api/auth')
app.include_router(main_router, prefix='/api')


Expand Down
83 changes: 83 additions & 0 deletions demo/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
from __future__ import annotations as _annotations

from typing import Annotated

from fastapi import APIRouter, Depends, Header
from fastui import AnyComponent, FastUI
from fastui import components as c
from fastui.events import AuthEvent, GoToEvent, PageEvent
from fastui.forms import fastui_form
from pydantic import BaseModel, EmailStr, Field, SecretStr

from . import db
from .shared import demo_page

router = APIRouter()


async def get_user(authorization: Annotated[str, Header()] = '') -> db.User | None:
try:
token = authorization.split(' ', 1)[1]
except IndexError:
return None
else:
return await db.get_user(token)


@router.get('/login', response_model=FastUI, response_model_exclude_none=True)
def auth_login(user: Annotated[str | None, Depends(get_user)]) -> list[AnyComponent]:
if user is None:
return demo_page(
c.Paragraph(
text=(
'This is a very simple demo of authentication, '
'here you can "login" with any email address and password.'
)
),
c.Heading(text='Login'),
c.ModelForm[LoginForm](submit_url='/api/auth/login'),
title='Authentication',
)
else:
return [c.FireEvent(event=GoToEvent(url='/auth/profile'))]


class LoginForm(BaseModel):
email: EmailStr = Field(title='Email Address', description='Enter whatever value you like')
password: SecretStr = Field(
title='Password',
description='Enter whatever value you like, password is not checked',
json_schema_extra={'autocomplete': 'current-password'},
)


@router.post('/login', response_model=FastUI, response_model_exclude_none=True)
async def login_form_post(form: Annotated[LoginForm, fastui_form(LoginForm)]) -> list[AnyComponent]:
token = await db.create_user(form.email)
return [c.FireEvent(event=AuthEvent(token=token, url='/auth/profile'))]


@router.get('/profile', response_model=FastUI, response_model_exclude_none=True)
async def profile(user: Annotated[db.User | None, Depends(get_user)]) -> list[AnyComponent]:
if user is None:
return [c.FireEvent(event=GoToEvent(url='/auth/login'))]
else:
active_count = await db.count_users()
return demo_page(
c.Paragraph(text=f'You are logged in as "{user.email}", {active_count} active users right now.'),
c.Button(text='Logout', on_click=PageEvent(name='submit-form')),
c.Form(
submit_url='/api/auth/logout',
form_fields=[c.FormFieldInput(name='test', title='', initial='data', html_type='hidden')],
footer=[],
submit_trigger=PageEvent(name='submit-form'),
),
title='Authentication',
)


@router.post('/logout', response_model=FastUI, response_model_exclude_none=True)
async def logout_form_post(user: Annotated[db.User | None, Depends(get_user)]) -> list[AnyComponent]:
if user is not None:
await db.delete_user(user)
return [c.FireEvent(event=AuthEvent(token=False, url='/auth/login'))]
6 changes: 4 additions & 2 deletions demo/components_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,11 +200,13 @@ class Delivery(BaseModel):
c.Div(
components=[
c.Heading(text='Custom', level=2),
c.Markdown(text="""\
c.Markdown(
text="""\
Below is a custom component, in this case it implements [cowsay](https://en.wikipedia.org/wiki/Cowsay),
but you might be able to do something even more useful with it.

The statement spoken by the famous cow is provided by the backend."""),
The statement spoken by the famous cow is provided by the backend."""
),
c.Custom(data='This is a custom component', sub_type='cowsay'),
],
class_name='border-top mt-3 pt-1',
Expand Down
73 changes: 73 additions & 0 deletions demo/db.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import os
import secrets
from contextlib import asynccontextmanager
from dataclasses import dataclass
from datetime import datetime

import libsql_client


@dataclass
class User:
token: str
email: str
last_active: datetime


async def get_user(token: str) -> User | None:
async with _connect() as conn:
rs = await conn.execute('select * from users where token = ?', (token,))
if rs.rows:
await conn.execute('update users set last_active = current_timestamp where token = ?', (token,))
return User(*rs.rows[0])


async def create_user(email: str) -> str:
async with _connect() as conn:
await _delete_old_users(conn)
token = secrets.token_hex()
await conn.execute('insert into users (token, email) values (?, ?)', (token, email))
return token


async def delete_user(user: User) -> None:
async with _connect() as conn:
await conn.execute('delete from users where token = ?', (user.token,))


async def count_users() -> int:
async with _connect() as conn:
await _delete_old_users(conn)
rs = await conn.execute('select count(*) from users')
return rs.rows[0][0]


async def create_db() -> None:
async with _connect() as conn:
rs = await conn.execute("select 1 from sqlite_master where type='table' and name='users'")
if not rs.rows:
await conn.execute(SCHEMA)


SCHEMA = """
create table if not exists users (
token varchar(255) primary key,
email varchar(255) not null unique,
last_active timestamp not null default current_timestamp
);
"""


async def _delete_old_users(conn: libsql_client.Client) -> None:
await conn.execute('delete from users where last_active < datetime(current_timestamp, "-1 hour")')


@asynccontextmanager
async def _connect() -> libsql_client.Client:
auth_token = os.getenv('SQLITE_AUTH_TOKEN')
if auth_token:
url = 'libsql://fastui-samuelcolvin.turso.io'
else:
url = 'file:users.db'
async with libsql_client.create_client(url, auth_token=auth_token) as conn:
yield conn
24 changes: 12 additions & 12 deletions demo/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from fastui import AnyComponent, FastUI
from fastui import components as c
from fastui.events import GoToEvent, PageEvent
from fastui.forms import FormFile, FormResponse, SelectSearchResponse, fastui_form
from fastui.forms import FormFile, SelectSearchResponse, fastui_form
from httpx import AsyncClient
from pydantic import BaseModel, EmailStr, Field, SecretStr, field_validator
from pydantic_core import PydanticCustomError
Expand Down Expand Up @@ -108,10 +108,10 @@ class LoginForm(BaseModel):
password: SecretStr


@router.post('/login')
async def login_form_post(form: Annotated[LoginForm, fastui_form(LoginForm)]) -> FormResponse:
# print(form)
return FormResponse(event=GoToEvent(url='/'))
@router.post('/login', response_model=FastUI, response_model_exclude_none=True)
async def login_form_post(form: Annotated[LoginForm, fastui_form(LoginForm)]):
print(form)
return [c.FireEvent(event=GoToEvent(url='/'))]


class ToolEnum(str, enum.Enum):
Expand All @@ -128,10 +128,10 @@ class SelectForm(BaseModel):
search_select_multiple: list[str] = Field(json_schema_extra={'search_url': '/api/forms/search'})


@router.post('/select')
async def select_form_post(form: Annotated[SelectForm, fastui_form(SelectForm)]) -> FormResponse:
@router.post('/select', response_model=FastUI, response_model_exclude_none=True)
async def select_form_post(form: Annotated[SelectForm, fastui_form(SelectForm)]):
# print(form)
return FormResponse(event=GoToEvent(url='/'))
return [c.FireEvent(event=GoToEvent(url='/'))]


class SizeModel(BaseModel):
Expand Down Expand Up @@ -162,7 +162,7 @@ def name_validator(cls, v: str | None) -> str:
return v


@router.post('/big')
async def big_form_post(form: Annotated[BigModel, fastui_form(BigModel)]) -> FormResponse:
# print(form)
return FormResponse(event=GoToEvent(url='/'))
@router.post('/big', response_model=FastUI, response_model_exclude_none=True)
async def big_form_post(form: Annotated[BigModel, fastui_form(BigModel)]):
print(form)
return [c.FireEvent(event=GoToEvent(url='/'))]
5 changes: 5 additions & 0 deletions demo/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ def demo_page(*components: AnyComponent, title: str | None = None) -> list[AnyCo
on_click=GoToEvent(url='/table/cities'),
active='startswith:/table',
),
c.Link(
components=[c.Text(text='Auth')],
on_click=GoToEvent(url='/auth/login'),
active='startswith:/auth',
),
c.Link(
components=[c.Text(text='Forms')],
on_click=GoToEvent(url='/forms/login'),
Expand Down
2 changes: 1 addition & 1 deletion demo/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def test_api_root():
{
'title': 'FastUI Demo',
'titleEvent': {'url': '/', 'type': 'go-to'},
'links': IsList(length=3),
'links': IsList(length=4),
'type': 'Navbar',
},
{
Expand Down
4 changes: 2 additions & 2 deletions src/npm-fastui-bootstrap/src/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ import BootstrapModal from 'react-bootstrap/Modal'
export const Modal: FC<components.ModalProps> = (props) => {
const { className, title, body, footer, openTrigger, openContext } = props

const { eventContext, clear } = events.usePageEventListen(openTrigger, openContext)
const { eventContext, fireId, clear } = events.usePageEventListen(openTrigger, openContext)

return (
<EventContextProvider context={eventContext}>
<BootstrapModal className={renderClassName(className)} show={!!eventContext} onHide={clear}>
<BootstrapModal className={renderClassName(className)} show={!!fireId} onHide={clear}>
<BootstrapModal.Header closeButton>
<BootstrapModal.Title>{title}</BootstrapModal.Title>
</BootstrapModal.Header>
Expand Down
27 changes: 27 additions & 0 deletions src/npm-fastui/src/components/FireEvent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { FC, useEffect, useRef } from 'react'

import { AnyEvent, useFireEvent } from '../events'
import { ClassName } from '../hooks/className'

export interface FireEventProps {
type: 'FireEvent'
event: AnyEvent
message?: string
// className is not used, but it's here to satisfy ClassName hooks type checks
className?: ClassName
}

export const FireEventComp: FC<FireEventProps> = ({ event, message }) => {
const { fireEvent } = useFireEvent()
const fireEventRef = useRef(fireEvent)

useEffect(() => {
fireEventRef.current = fireEvent
}, [fireEvent])

useEffect(() => {
fireEventRef.current(event)
}, [event, fireEventRef])

return <>{message}</>
}
2 changes: 1 addition & 1 deletion src/npm-fastui/src/components/FormField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export type FormFieldProps =

interface FormFieldInputProps extends BaseFormFieldProps {
type: 'FormFieldInput'
htmlType: 'text' | 'date' | 'datetime-local' | 'time' | 'email' | 'url' | 'number' | 'password'
htmlType: 'text' | 'date' | 'datetime-local' | 'time' | 'email' | 'url' | 'number' | 'password' | 'hidden'
initial?: string | number
placeholder?: string
}
Expand Down
Loading