Skip to content

Commit 56bae9e

Browse files
authored
Authorisation (#93)
1 parent e7e3600 commit 56bae9e

30 files changed

+550
-123
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,4 @@ __pycache__/
3333
/scratch/
3434
/packages-dist/
3535
/.coverage
36+
/users.db

Makefile

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@ update-lockfiles:
1818

1919
.PHONY: format
2020
format:
21-
ruff check --fix-only $(path)
22-
ruff format $(path)
21+
ruff check --fix-only $(path) demo
22+
ruff format $(path) demo
2323

2424
.PHONY: lint
2525
lint:
26-
ruff check $(path)
27-
ruff format --check $(path)
26+
ruff check $(path) demo
27+
ruff format --check $(path) demo
2828

2929
.PHONY: typecheck
3030
typecheck:
@@ -40,7 +40,7 @@ testcov: test
4040

4141
.PHONY: dev
4242
dev:
43-
uvicorn demo:app --reload
43+
uvicorn demo:app --reload --reload-dir .
4444

4545
.PHONY: all
4646
all: testcov lint

demo/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,17 @@
99
from fastui.dev import dev_fastapi_app
1010
from httpx import AsyncClient
1111

12+
from .auth import router as auth_router
1213
from .components_list import router as components_router
14+
from .db import create_db
1315
from .forms import router as forms_router
1416
from .main import router as main_router
1517
from .tables import router as table_router
1618

1719

1820
@asynccontextmanager
1921
async def lifespan(app_: FastAPI):
22+
await create_db()
2023
async with AsyncClient() as client:
2124
app_.state.httpx_client = client
2225
yield
@@ -32,6 +35,7 @@ async def lifespan(app_: FastAPI):
3235
app.include_router(components_router, prefix='/api/components')
3336
app.include_router(table_router, prefix='/api/table')
3437
app.include_router(forms_router, prefix='/api/forms')
38+
app.include_router(auth_router, prefix='/api/auth')
3539
app.include_router(main_router, prefix='/api')
3640

3741

demo/auth.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
from __future__ import annotations as _annotations
2+
3+
from typing import Annotated
4+
5+
from fastapi import APIRouter, Depends, Header
6+
from fastui import AnyComponent, FastUI
7+
from fastui import components as c
8+
from fastui.events import AuthEvent, GoToEvent, PageEvent
9+
from fastui.forms import fastui_form
10+
from pydantic import BaseModel, EmailStr, Field, SecretStr
11+
12+
from . import db
13+
from .shared import demo_page
14+
15+
router = APIRouter()
16+
17+
18+
async def get_user(authorization: Annotated[str, Header()] = '') -> db.User | None:
19+
try:
20+
token = authorization.split(' ', 1)[1]
21+
except IndexError:
22+
return None
23+
else:
24+
return await db.get_user(token)
25+
26+
27+
@router.get('/login', response_model=FastUI, response_model_exclude_none=True)
28+
def auth_login(user: Annotated[str | None, Depends(get_user)]) -> list[AnyComponent]:
29+
if user is None:
30+
return demo_page(
31+
c.Paragraph(
32+
text=(
33+
'This is a very simple demo of authentication, '
34+
'here you can "login" with any email address and password.'
35+
)
36+
),
37+
c.Heading(text='Login'),
38+
c.ModelForm[LoginForm](submit_url='/api/auth/login'),
39+
title='Authentication',
40+
)
41+
else:
42+
return [c.FireEvent(event=GoToEvent(url='/auth/profile'))]
43+
44+
45+
class LoginForm(BaseModel):
46+
email: EmailStr = Field(title='Email Address', description='Enter whatever value you like')
47+
password: SecretStr = Field(
48+
title='Password',
49+
description='Enter whatever value you like, password is not checked',
50+
json_schema_extra={'autocomplete': 'current-password'},
51+
)
52+
53+
54+
@router.post('/login', response_model=FastUI, response_model_exclude_none=True)
55+
async def login_form_post(form: Annotated[LoginForm, fastui_form(LoginForm)]) -> list[AnyComponent]:
56+
token = await db.create_user(form.email)
57+
return [c.FireEvent(event=AuthEvent(token=token, url='/auth/profile'))]
58+
59+
60+
@router.get('/profile', response_model=FastUI, response_model_exclude_none=True)
61+
async def profile(user: Annotated[db.User | None, Depends(get_user)]) -> list[AnyComponent]:
62+
if user is None:
63+
return [c.FireEvent(event=GoToEvent(url='/auth/login'))]
64+
else:
65+
active_count = await db.count_users()
66+
return demo_page(
67+
c.Paragraph(text=f'You are logged in as "{user.email}", {active_count} active users right now.'),
68+
c.Button(text='Logout', on_click=PageEvent(name='submit-form')),
69+
c.Form(
70+
submit_url='/api/auth/logout',
71+
form_fields=[c.FormFieldInput(name='test', title='', initial='data', html_type='hidden')],
72+
footer=[],
73+
submit_trigger=PageEvent(name='submit-form'),
74+
),
75+
title='Authentication',
76+
)
77+
78+
79+
@router.post('/logout', response_model=FastUI, response_model_exclude_none=True)
80+
async def logout_form_post(user: Annotated[db.User | None, Depends(get_user)]) -> list[AnyComponent]:
81+
if user is not None:
82+
await db.delete_user(user)
83+
return [c.FireEvent(event=AuthEvent(token=False, url='/auth/login'))]

demo/components_list.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,11 +200,13 @@ class Delivery(BaseModel):
200200
c.Div(
201201
components=[
202202
c.Heading(text='Custom', level=2),
203-
c.Markdown(text="""\
203+
c.Markdown(
204+
text="""\
204205
Below is a custom component, in this case it implements [cowsay](https://en.wikipedia.org/wiki/Cowsay),
205206
but you might be able to do something even more useful with it.
206207
207-
The statement spoken by the famous cow is provided by the backend."""),
208+
The statement spoken by the famous cow is provided by the backend."""
209+
),
208210
c.Custom(data='This is a custom component', sub_type='cowsay'),
209211
],
210212
class_name='border-top mt-3 pt-1',

demo/db.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import os
2+
import secrets
3+
from contextlib import asynccontextmanager
4+
from dataclasses import dataclass
5+
from datetime import datetime
6+
7+
import libsql_client
8+
9+
10+
@dataclass
11+
class User:
12+
token: str
13+
email: str
14+
last_active: datetime
15+
16+
17+
async def get_user(token: str) -> User | None:
18+
async with _connect() as conn:
19+
rs = await conn.execute('select * from users where token = ?', (token,))
20+
if rs.rows:
21+
await conn.execute('update users set last_active = current_timestamp where token = ?', (token,))
22+
return User(*rs.rows[0])
23+
24+
25+
async def create_user(email: str) -> str:
26+
async with _connect() as conn:
27+
await _delete_old_users(conn)
28+
token = secrets.token_hex()
29+
await conn.execute('insert into users (token, email) values (?, ?)', (token, email))
30+
return token
31+
32+
33+
async def delete_user(user: User) -> None:
34+
async with _connect() as conn:
35+
await conn.execute('delete from users where token = ?', (user.token,))
36+
37+
38+
async def count_users() -> int:
39+
async with _connect() as conn:
40+
await _delete_old_users(conn)
41+
rs = await conn.execute('select count(*) from users')
42+
return rs.rows[0][0]
43+
44+
45+
async def create_db() -> None:
46+
async with _connect() as conn:
47+
rs = await conn.execute("select 1 from sqlite_master where type='table' and name='users'")
48+
if not rs.rows:
49+
await conn.execute(SCHEMA)
50+
51+
52+
SCHEMA = """
53+
create table if not exists users (
54+
token varchar(255) primary key,
55+
email varchar(255) not null unique,
56+
last_active timestamp not null default current_timestamp
57+
);
58+
"""
59+
60+
61+
async def _delete_old_users(conn: libsql_client.Client) -> None:
62+
await conn.execute('delete from users where last_active < datetime(current_timestamp, "-1 hour")')
63+
64+
65+
@asynccontextmanager
66+
async def _connect() -> libsql_client.Client:
67+
auth_token = os.getenv('SQLITE_AUTH_TOKEN')
68+
if auth_token:
69+
url = 'libsql://fastui-samuelcolvin.turso.io'
70+
else:
71+
url = 'file:users.db'
72+
async with libsql_client.create_client(url, auth_token=auth_token) as conn:
73+
yield conn

demo/forms.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from fastui import AnyComponent, FastUI
1010
from fastui import components as c
1111
from fastui.events import GoToEvent, PageEvent
12-
from fastui.forms import FormFile, FormResponse, SelectSearchResponse, fastui_form
12+
from fastui.forms import FormFile, SelectSearchResponse, fastui_form
1313
from httpx import AsyncClient
1414
from pydantic import BaseModel, EmailStr, Field, SecretStr, field_validator
1515
from pydantic_core import PydanticCustomError
@@ -108,10 +108,10 @@ class LoginForm(BaseModel):
108108
password: SecretStr
109109

110110

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

116116

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

130130

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

136136

137137
class SizeModel(BaseModel):
@@ -162,7 +162,7 @@ def name_validator(cls, v: str | None) -> str:
162162
return v
163163

164164

165-
@router.post('/big')
166-
async def big_form_post(form: Annotated[BigModel, fastui_form(BigModel)]) -> FormResponse:
167-
# print(form)
168-
return FormResponse(event=GoToEvent(url='/'))
165+
@router.post('/big', response_model=FastUI, response_model_exclude_none=True)
166+
async def big_form_post(form: Annotated[BigModel, fastui_form(BigModel)]):
167+
print(form)
168+
return [c.FireEvent(event=GoToEvent(url='/'))]

demo/shared.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ def demo_page(*components: AnyComponent, title: str | None = None) -> list[AnyCo
2222
on_click=GoToEvent(url='/table/cities'),
2323
active='startswith:/table',
2424
),
25+
c.Link(
26+
components=[c.Text(text='Auth')],
27+
on_click=GoToEvent(url='/auth/login'),
28+
active='startswith:/auth',
29+
),
2530
c.Link(
2631
components=[c.Text(text='Forms')],
2732
on_click=GoToEvent(url='/forms/login'),

demo/tests.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ def test_api_root():
2828
{
2929
'title': 'FastUI Demo',
3030
'titleEvent': {'url': '/', 'type': 'go-to'},
31-
'links': IsList(length=3),
31+
'links': IsList(length=4),
3232
'type': 'Navbar',
3333
},
3434
{

src/npm-fastui-bootstrap/src/modal.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ import BootstrapModal from 'react-bootstrap/Modal'
55
export const Modal: FC<components.ModalProps> = (props) => {
66
const { className, title, body, footer, openTrigger, openContext } = props
77

8-
const { eventContext, clear } = events.usePageEventListen(openTrigger, openContext)
8+
const { eventContext, fireId, clear } = events.usePageEventListen(openTrigger, openContext)
99

1010
return (
1111
<EventContextProvider context={eventContext}>
12-
<BootstrapModal className={renderClassName(className)} show={!!eventContext} onHide={clear}>
12+
<BootstrapModal className={renderClassName(className)} show={!!fireId} onHide={clear}>
1313
<BootstrapModal.Header closeButton>
1414
<BootstrapModal.Title>{title}</BootstrapModal.Title>
1515
</BootstrapModal.Header>
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { FC, useEffect, useRef } from 'react'
2+
3+
import { AnyEvent, useFireEvent } from '../events'
4+
import { ClassName } from '../hooks/className'
5+
6+
export interface FireEventProps {
7+
type: 'FireEvent'
8+
event: AnyEvent
9+
message?: string
10+
// className is not used, but it's here to satisfy ClassName hooks type checks
11+
className?: ClassName
12+
}
13+
14+
export const FireEventComp: FC<FireEventProps> = ({ event, message }) => {
15+
const { fireEvent } = useFireEvent()
16+
const fireEventRef = useRef(fireEvent)
17+
18+
useEffect(() => {
19+
fireEventRef.current = fireEvent
20+
}, [fireEvent])
21+
22+
useEffect(() => {
23+
fireEventRef.current(event)
24+
}, [event, fireEventRef])
25+
26+
return <>{message}</>
27+
}

src/npm-fastui/src/components/FormField.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export type FormFieldProps =
2828

2929
interface FormFieldInputProps extends BaseFormFieldProps {
3030
type: 'FormFieldInput'
31-
htmlType: 'text' | 'date' | 'datetime-local' | 'time' | 'email' | 'url' | 'number' | 'password'
31+
htmlType: 'text' | 'date' | 'datetime-local' | 'time' | 'email' | 'url' | 'number' | 'password' | 'hidden'
3232
initial?: string | number
3333
placeholder?: string
3434
}

0 commit comments

Comments
 (0)