work-in-progress atproto PDS
typescript atproto pds atcute

refactor: use oomfware

mary.my.id 241ca27f ee5a8173

verified
+1
.prettierrc
··· 15 15 "^@atcute/(.*)$", 16 16 "^@danaus/(.*)$", 17 17 "^@kelinci/(.*)$", 18 + "^@oomfware/(.*)$", 18 19 "", 19 20 "<THIRD_PARTY_MODULES>", 20 21 "",
+3 -1
packages/danaus/package.json
··· 42 42 "@atcute/xrpc-server": "^0.1.8", 43 43 "@atcute/xrpc-server-bun": "^0.1.1", 44 44 "@kelinci/danaus-lexicons": "workspace:*", 45 + "@oomfware/fetch-router": "^0.2.1", 46 + "@oomfware/forms": "^0.2.0", 47 + "@oomfware/jsx": "^0.1.4", 45 48 "cva": "1.0.0-beta.4", 46 49 "drizzle-orm": "1.0.0-beta.6-4414a19", 47 50 "get-port": "^7.1.0", 48 - "hono": "^4.11.3", 49 51 "jose": "^6.1.3", 50 52 "nanoid": "^5.1.6", 51 53 "p-queue": "^9.1.0",
+3 -3
packages/danaus/src/pds-server.ts
··· 9 9 import type { AppConfig } from './config.ts'; 10 10 import { createAppContext, type AppContext } from './context.ts'; 11 11 import { createProxyMiddleware } from './proxy/index.ts'; 12 - import { createWebApp } from './web/app.ts'; 12 + import { createWebRouter } from './web/router.ts'; 13 13 import styles from './web/styles/main.out.css' with { type: 'file' }; 14 14 15 15 export interface PdsServerOptions { ··· 74 74 comAtproto(router, context); 75 75 localDanaus(router, context); 76 76 77 - const web = createWebApp(context); 77 + const web = createWebRouter(context); 78 78 79 79 const corsHeaders = { 'access-control-allow-origin': '*' }; 80 80 ··· 119 119 '/xrpc/*': wrapped.fetch, 120 120 121 121 '/assets/style.css': new Response(Bun.file(styles), { headers: { 'cache-control': 'no-cache' } }), 122 - '/*': web.fetch, 122 + '/*': (request) => web.fetch(request), 123 123 }, 124 124 }); 125 125 disposables.defer(() => server.stop());
+169 -198
packages/danaus/src/web/account/forms.ts
··· 2 2 import type { Did, Handle } from '@atcute/lexicons'; 3 3 import { isHandle } from '@atcute/lexicons/syntax'; 4 4 import { XRPCError } from '@atcute/xrpc-server'; 5 + import { redirect } from '@oomfware/fetch-router'; 6 + import { getContext } from '@oomfware/fetch-router/middlewares/async-context'; 7 + import { form, invalid } from '@oomfware/forms'; 5 8 6 - import { HTTPException } from 'hono/http-exception'; 7 9 import * as v from 'valibot'; 8 10 9 11 import { parseAppPasswordPrivilege } from '#app/accounts/app-passwords.ts'; 10 12 import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH } from '#app/accounts/passwords.ts'; 11 - import { readWebSessionToken, setWebSessionToken, verifyWebSessionToken } from '#app/auth/web.ts'; 13 + import { setWebSessionToken } from '#app/auth/web.ts'; 12 14 import type { AppContext } from '#app/context.ts'; 13 15 import { isHostnameSuffix } from '#app/utils/schema.ts'; 14 16 15 - import { form, getRequestContext, invalid, redirect } from '../forms/index.ts'; 17 + import { getAppContext } from '../middlewares/app-context.ts'; 18 + import { getSession } from '../middlewares/session.ts'; 16 19 17 - export const createAccountForms = (ctx: AppContext) => { 18 - const { accountManager } = ctx; 19 - 20 - const verifyCredentials = () => { 21 - const c = getRequestContext(); 22 - const token = readWebSessionToken(c.req.raw); 23 - if (!token) { 24 - throw new HTTPException(302, { 25 - res: c.redirect(`/account/login?redirect=${encodeURIComponent(c.req.path)}`), 26 - }); 27 - } 20 + /** 21 + * validates credentials, creates session, sets cookie, and redirects. 22 + */ 23 + export const signInForm = form( 24 + v.object({ 25 + identifier: v.pipe(v.string(), v.minLength(1, `Enter your email or username`)), 26 + _password: v.pipe(v.string(), v.minLength(1, `Enter your password`)), 27 + remember: v.optional(v.boolean()), 28 + redirect: v.optional(v.string()), 29 + }), 30 + async (data, issue) => { 31 + const { accountManager } = getAppContext(); 32 + const { request } = getContext(); 28 33 29 - const sessionId = verifyWebSessionToken(ctx.config.secrets.jwtKey, token); 30 - if (!sessionId) { 31 - throw new HTTPException(302, { 32 - res: c.redirect(`/account/login?redirect=${encodeURIComponent(c.req.path)}`), 33 - }); 34 + if (data._password.length < MIN_PASSWORD_LENGTH || data._password.length > MAX_PASSWORD_LENGTH) { 35 + invalid(issue.identifier(`Invalid account credentials`)); 34 36 } 35 37 36 - const session = accountManager.getWebSession(sessionId); 37 - if (!session) { 38 - throw new HTTPException(302, { 39 - res: c.redirect(`/account/login?redirect=${encodeURIComponent(c.req.path)}`), 40 - }); 38 + const account = await accountManager.verifyAccountPassword(data.identifier, data._password); 39 + if (account === null) { 40 + invalid(issue.identifier(`Invalid account credentials`)); 41 41 } 42 42 43 - return session; 44 - }; 43 + const { session, token } = await accountManager.createWebSession({ 44 + did: account.did, 45 + remember: data.remember ?? false, 46 + userAgent: request.headers.get('user-agent') ?? undefined, 47 + }); 45 48 46 - /** 47 - * validates credentials, creates session, sets cookie, and redirects. 48 - */ 49 - const signInForm = form( 50 - v.object({ 51 - identifier: v.pipe(v.string(), v.minLength(1, `Enter your email or username`)), 52 - password: v.pipe(v.string(), v.minLength(1, `Enter your password`)), 53 - remember: v.optional(v.boolean()), 54 - redirect: v.optional(v.string()), 55 - }), 56 - async (data, issue) => { 57 - const c = getRequestContext(); 49 + setWebSessionToken(request, token, { 50 + expires: session.expires_at, 51 + httpOnly: true, 52 + sameSite: 'lax', 53 + path: '/', 54 + }); 58 55 59 - if (data.password.length < MIN_PASSWORD_LENGTH || data.password.length > MAX_PASSWORD_LENGTH) { 60 - invalid(issue.identifier(`Invalid account credentials`)); 61 - } 56 + redirect(data.redirect ?? '/account'); 57 + }, 58 + ); 62 59 63 - const account = await accountManager.verifyAccountPassword(data.identifier, data.password); 64 - if (account === null) { 65 - invalid(issue.identifier(`Invalid account credentials`)); 66 - } 60 + /** 61 + * creates an app password and returns the secret for display. 62 + */ 63 + export const createAppPasswordForm = form( 64 + v.object({ 65 + name: v.pipe(v.string(), v.minLength(1, `Name is required`), v.maxLength(32, `Name is too long`)), 66 + privilege: v.picklist(['limited', 'privileged', 'full'], `Invalid privilege`), 67 + }), 68 + async (data) => { 69 + const { accountManager } = getAppContext(); 70 + const session = getSession(); 67 71 68 - const { session, token } = await accountManager.createWebSession({ 69 - did: account.did, 70 - remember: data.remember ?? false, 71 - userAgent: c.req.header('user-agent'), 72 - }); 72 + const privilege = parseAppPasswordPrivilege(data.privilege); 73 73 74 - setWebSessionToken(c.req.raw, token, { 75 - expires: session.expires_at, 76 - httpOnly: true, 77 - sameSite: 'lax', 78 - path: '/', 74 + try { 75 + const { appPassword, secret } = await accountManager.createAppPassword({ 76 + did: session.did, 77 + name: data.name, 78 + privilege, 79 79 }); 80 80 81 - redirect(302, data.redirect ?? '/account'); 82 - }, 83 - ); 84 - 85 - /** 86 - * creates an app password and returns the secret for display. 87 - */ 88 - const createAppPasswordForm = form( 89 - v.object({ 90 - name: v.pipe(v.string(), v.minLength(1, `Name is required`), v.maxLength(32, `Name is too long`)), 91 - privilege: v.picklist(['limited', 'privileged', 'full'], `Invalid privilege`), 92 - }), 93 - async (data) => { 94 - const session = verifyCredentials(); 95 - const privilege = parseAppPasswordPrivilege(data.privilege); 96 - 97 - try { 98 - const { appPassword, secret } = await accountManager.createAppPassword({ 99 - did: session.did, 100 - name: data.name, 101 - privilege, 102 - }); 103 - 104 - return { name: appPassword.name, secret }; 105 - } catch (err) { 106 - if (err instanceof XRPCError && err.status === 400) { 107 - switch (err.error) { 108 - case 'DuplicateAppPassword': { 109 - invalid(`An app password with this name already exists`); 110 - } 111 - case 'TooManyAppPasswords': { 112 - invalid(`You've reached the maximum amount of app passwords allowed`); 113 - } 81 + return { name: appPassword.name, secret }; 82 + } catch (err) { 83 + if (err instanceof XRPCError && err.status === 400) { 84 + switch (err.error) { 85 + case 'DuplicateAppPassword': { 86 + invalid(`An app password with this name already exists`); 87 + } 88 + case 'TooManyAppPasswords': { 89 + invalid(`You've reached the maximum amount of app passwords allowed`); 114 90 } 115 91 } 116 - 117 - throw err; 118 92 } 119 - }, 120 - ); 121 93 122 - /** 123 - * deletes an app password and redirects back to the list. 124 - */ 125 - const deleteAppPasswordForm = form( 126 - v.object({ 127 - name: v.pipe(v.string(), v.minLength(1)), 128 - }), 129 - async (data) => { 130 - const session = verifyCredentials(); 94 + throw err; 95 + } 96 + }, 97 + ); 131 98 132 - accountManager.deleteAppPassword(session.did, data.name); 133 - }, 134 - ); 99 + /** 100 + * deletes an app password. 101 + */ 102 + export const deleteAppPasswordForm = form( 103 + v.object({ 104 + name: v.pipe(v.string(), v.minLength(1)), 105 + }), 106 + async (data) => { 107 + const { accountManager } = getAppContext(); 108 + const session = getSession(); 135 109 136 - /** 137 - * updates the account handle, including PLC document for did:plc accounts. 138 - */ 139 - const updateHandleForm = form( 140 - v.object({ 141 - domain: v.union([v.pipe(v.string(), v.check(isHostnameSuffix)), v.literal('custom')]), 142 - handle: v.pipe(v.string(), v.minLength(1, `Handle is required`)), 143 - }), 144 - async (data) => { 145 - const { did } = verifyCredentials(); 110 + accountManager.deleteAppPassword(session.did, data.name); 111 + }, 112 + ); 146 113 147 - let handle: Handle; 148 - if (data.domain === 'custom') { 149 - if (!isHandle(data.handle)) { 150 - invalid(`Invalid handle`); 151 - } 114 + /** 115 + * updates the account handle, including PLC document for did:plc accounts. 116 + */ 117 + export const updateHandleForm = form( 118 + v.object({ 119 + domain: v.union([v.pipe(v.string(), v.check(isHostnameSuffix)), v.literal('custom')]), 120 + handle: v.pipe(v.string(), v.minLength(1, `Handle is required`)), 121 + }), 122 + async (data) => { 123 + const ctx = getAppContext(); 124 + const { did } = getSession(); 152 125 153 - handle = data.handle; 154 - } else { 155 - const fullHandle = `${data.handle}${data.domain}`; 156 - if (!isHandle(fullHandle)) { 157 - invalid(`Invalid handle`); 158 - } 126 + let handle: Handle; 127 + if (data.domain === 'custom') { 128 + if (!isHandle(data.handle)) { 129 + invalid(`Invalid handle`); 130 + } 159 131 160 - handle = fullHandle; 132 + handle = data.handle; 133 + } else { 134 + const fullHandle = `${data.handle}${data.domain}`; 135 + if (!isHandle(fullHandle)) { 136 + invalid(`Invalid handle`); 161 137 } 162 138 163 - // validate the handle (checks TLD, service domain constraints, external domain resolution) 164 - try { 165 - handle = await accountManager.validateHandle(handle, { did }); 166 - } catch (err) { 167 - if (err instanceof XRPCError && err.status === 400) { 168 - switch (err.error) { 169 - case 'InvalidHandle': { 170 - invalid(err.description ?? `Invalid handle`); 171 - } 172 - case 'UnsupportedDomain': { 173 - invalid(`Handle must resolve to your DID via DNS or .well-known`); 174 - } 139 + handle = fullHandle; 140 + } 141 + 142 + // validate the handle (checks TLD, service domain constraints, external domain resolution) 143 + try { 144 + handle = await ctx.accountManager.validateHandle(handle, { did }); 145 + } catch (err) { 146 + if (err instanceof XRPCError && err.status === 400) { 147 + switch (err.error) { 148 + case 'InvalidHandle': { 149 + invalid(err.description ?? `Invalid handle`); 150 + } 151 + case 'UnsupportedDomain': { 152 + invalid(`Handle must resolve to your DID via DNS or .well-known`); 175 153 } 176 154 } 177 - throw err; 178 155 } 156 + throw err; 157 + } 179 158 180 - // check if handle is already taken by another account 181 - const existing = accountManager.getAccount(handle, { 182 - includeDeactivated: true, 183 - includeTakenDown: true, 184 - }); 159 + // check if handle is already taken by another account 160 + const existing = ctx.accountManager.getAccount(handle, { 161 + includeDeactivated: true, 162 + includeTakenDown: true, 163 + }); 185 164 186 - if (existing !== null) { 187 - if (existing.did === did) { 188 - return; 189 - } 190 - 191 - invalid(`Handle is already taken`); 165 + if (existing !== null) { 166 + if (existing.did === did) { 167 + return; 192 168 } 193 169 194 - // update PLC document for did:plc accounts 195 - if (did.startsWith('did:plc:')) { 196 - try { 197 - await updatePlcHandle(ctx, did as Did<'plc'>, handle); 198 - } catch (err) { 199 - if (err instanceof PlcClientError) { 200 - invalid(`Unable to update DID document, please try again later`); 201 - } 170 + invalid(`Handle is already taken`); 171 + } 202 172 203 - throw err; 173 + // update PLC document for did:plc accounts 174 + if (did.startsWith('did:plc:')) { 175 + try { 176 + await updatePlcHandle(ctx, did as Did<'plc'>, handle); 177 + } catch (err) { 178 + if (err instanceof PlcClientError) { 179 + invalid(`Unable to update DID document, please try again later`); 204 180 } 181 + 182 + throw err; 205 183 } 184 + } 206 185 207 - // update local database and emit identity event 208 - accountManager.updateAccountHandle(did, handle); 209 - await ctx.sequencer.emitIdentity(did, handle); 210 - }, 211 - ); 186 + // update local database and emit identity event 187 + ctx.accountManager.updateAccountHandle(did, handle); 188 + await ctx.sequencer.emitIdentity(did, handle); 189 + }, 190 + ); 212 191 213 - /** 214 - * triggers identity event to refresh handle caches after verifying handle still resolves. 215 - */ 216 - const refreshHandleForm = form(v.object({}), async () => { 217 - const { did } = verifyCredentials(); 192 + /** 193 + * triggers identity event to refresh handle caches after verifying handle still resolves. 194 + */ 195 + export const refreshHandleForm = form(v.object({}), async () => { 196 + const { accountManager, sequencer } = getAppContext(); 197 + const { did } = getSession(); 218 198 219 - const account = accountManager.getAccount(did)!; 220 - if (!account.handle) { 221 - invalid(`Handle not set`); 222 - } 199 + const account = accountManager.getAccount(did)!; 200 + if (!account.handle) { 201 + invalid(`Handle not set`); 202 + } 223 203 224 - // verify handle still resolves correctly 225 - try { 226 - await accountManager.validateHandle(account.handle, { did }); 227 - } catch (err) { 228 - if (err instanceof XRPCError && err.status === 400) { 229 - switch (err.error) { 230 - case 'InvalidHandle': { 231 - invalid(err.description ?? `Handle is no longer valid`); 232 - } 233 - case 'UnsupportedDomain': { 234 - invalid(`Handle no longer resolves to your DID`); 235 - } 204 + // verify handle still resolves correctly 205 + try { 206 + await accountManager.validateHandle(account.handle, { did }); 207 + } catch (err) { 208 + if (err instanceof XRPCError && err.status === 400) { 209 + switch (err.error) { 210 + case 'InvalidHandle': { 211 + invalid(err.description ?? `Handle is no longer valid`); 212 + } 213 + case 'UnsupportedDomain': { 214 + invalid(`Handle no longer resolves to your DID`); 236 215 } 237 216 } 238 - throw err; 239 217 } 240 - 241 - // emit identity event with current handle to trigger cache refresh 242 - await ctx.sequencer.emitIdentity(did, account.handle); 243 - }); 218 + throw err; 219 + } 244 220 245 - return { 246 - signInForm, 247 - createAppPasswordForm, 248 - deleteAppPasswordForm, 249 - updateHandleForm, 250 - refreshHandleForm, 251 - }; 252 - }; 221 + // emit identity event with current handle to trigger cache refresh 222 + await sequencer.emitIdentity(did, account.handle); 223 + }); 253 224 254 225 /** 255 226 * updates the handle in a did:plc document.
-851
packages/danaus/src/web/account/index.tsx
··· 1 - import type { Did } from '@atcute/lexicons'; 2 - 3 - import { Hono, type Context } from 'hono'; 4 - import { HTTPException } from 'hono/http-exception'; 5 - import { jsxRenderer } from 'hono/jsx-renderer'; 6 - 7 - import { AppPasswordPrivilege } from '#app/accounts/db/schema.ts'; 8 - import type { WebSession } from '#app/accounts/manager.ts'; 9 - import { readWebSessionToken, verifyWebSessionToken } from '#app/auth/web.ts'; 10 - import type { AppContext } from '#app/context.ts'; 11 - 12 - import AsideItem from '../admin/components/aside-item.tsx'; 13 - import { IdProvider } from '../components/id.tsx'; 14 - import { registerForms } from '../forms/index.ts'; 15 - import AtOutlined from '../icons/central/at-outlined.tsx'; 16 - import DotGrid1x3HorizontalOutlined from '../icons/central/dot-grid-1x3-horizontal-outlined.tsx'; 17 - import Key2Outlined from '../icons/central/key-2-outlined.tsx'; 18 - import PasskeysOutlined from '../icons/central/passkeys-outlined.tsx'; 19 - import PasswordOutlined from '../icons/central/password-outlined.tsx'; 20 - import PersonOutlined from '../icons/central/person-outlined.tsx'; 21 - import PhoneOutlined from '../icons/central/phone-outlined.tsx'; 22 - import PlusLargeOutlined from '../icons/central/plus-large-outlined.tsx'; 23 - import ShieldOutlined from '../icons/central/shield-outlined.tsx'; 24 - import UsbOutlined from '../icons/central/usb-outlined.tsx'; 25 - import AccordionHeader from '../primitives/accordion-header.tsx'; 26 - import AccordionItem from '../primitives/accordion-item.tsx'; 27 - import AccordionPanel from '../primitives/accordion-panel.tsx'; 28 - import Accordion from '../primitives/accordion.tsx'; 29 - import Button from '../primitives/button.tsx'; 30 - import DialogActions from '../primitives/dialog-actions.tsx'; 31 - import DialogBody from '../primitives/dialog-body.tsx'; 32 - import DialogClose from '../primitives/dialog-close.tsx'; 33 - import DialogContent from '../primitives/dialog-content.tsx'; 34 - import DialogSurface from '../primitives/dialog-surface.tsx'; 35 - import DialogTitle from '../primitives/dialog-title.tsx'; 36 - import DialogTrigger from '../primitives/dialog-trigger.tsx'; 37 - import Dialog from '../primitives/dialog.tsx'; 38 - import Field from '../primitives/field.tsx'; 39 - import Input from '../primitives/input.tsx'; 40 - import MenuDivider from '../primitives/menu-divider.tsx'; 41 - import MenuItem from '../primitives/menu-item.tsx'; 42 - import MenuList from '../primitives/menu-list.tsx'; 43 - import MenuPopover from '../primitives/menu-popover.tsx'; 44 - import MenuTrigger from '../primitives/menu-trigger.tsx'; 45 - import Menu from '../primitives/menu.tsx'; 46 - import MessageBarBody from '../primitives/message-bar-body.tsx'; 47 - import MessageBarTitle from '../primitives/message-bar-title.tsx'; 48 - import MessageBar from '../primitives/message-bar.tsx'; 49 - import Select from '../primitives/select.tsx'; 50 - 51 - import { createAccountForms } from './forms.ts'; 52 - 53 - export const createAccountApp = (ctx: AppContext) => { 54 - const app = new Hono(); 55 - 56 - const forms = createAccountForms(ctx); 57 - app.use(registerForms(forms)); 58 - 59 - // #region verify credentials helper 60 - const verifyCredentials = (c: Context): WebSession => { 61 - const token = readWebSessionToken(c.req.raw); 62 - if (!token) { 63 - throw new HTTPException(302, { 64 - res: c.redirect(`/account/login?redirect=${encodeURIComponent(c.req.path)}`), 65 - }); 66 - } 67 - 68 - const sessionId = verifyWebSessionToken(ctx.config.secrets.jwtKey, token); 69 - if (!sessionId) { 70 - throw new HTTPException(302, { 71 - res: c.redirect(`/account/login?redirect=${encodeURIComponent(c.req.path)}`), 72 - }); 73 - } 74 - 75 - const session = ctx.accountManager.getWebSession(sessionId); 76 - if (!session) { 77 - throw new HTTPException(302, { 78 - res: c.redirect(`/account/login?redirect=${encodeURIComponent(c.req.path)}`), 79 - }); 80 - } 81 - 82 - return session; 83 - }; 84 - // #endregion 85 - 86 - // #region base HTML renderer 87 - app.use( 88 - jsxRenderer(({ children }) => { 89 - return ( 90 - <IdProvider> 91 - <html lang="en"> 92 - <head> 93 - <meta charset="utf-8" /> 94 - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 95 - <link rel="stylesheet" href="/assets/style.css" /> 96 - </head> 97 - 98 - <body> 99 - <div class="flex min-h-dvh flex-col">{children}</div> 100 - </body> 101 - </html> 102 - </IdProvider> 103 - ); 104 - }), 105 - ); 106 - // #endregion 107 - 108 - // #region login route (unauthenticated) 109 - app.on(['GET', 'POST'], '/login', (c) => { 110 - const { signInForm } = forms; 111 - const { fields } = signInForm; 112 - 113 - return c.render( 114 - <> 115 - <title>sign in - danaus</title> 116 - 117 - <div class="flex flex-1 items-center justify-center p-4"> 118 - <div class="w-full max-w-96 rounded-xl bg-neutral-background-1 p-6 shadow-16"> 119 - <form {...signInForm} class="flex flex-col gap-6"> 120 - <h1 class="text-base-500 font-semibold">Sign in to your account</h1> 121 - 122 - <Field 123 - label="Handle or email" 124 - required 125 - validationMessageText={fields.identifier.issues()[0]?.message} 126 - > 127 - <Input {...fields.identifier.as('text')} placeholder="alice.bsky.social" required autofocus /> 128 - </Field> 129 - 130 - <Field label="Password" required validationMessageText={fields.password.issues()[0]?.message}> 131 - <Input {...fields.password.as('password')} required /> 132 - </Field> 133 - 134 - <Button type="submit" variant="primary"> 135 - Sign in 136 - </Button> 137 - </form> 138 - </div> 139 - </div> 140 - </>, 141 - ); 142 - }); 143 - // #endregion 144 - 145 - // #region overview route 146 - app.on(['GET', 'POST'], '/', (c) => { 147 - const session = verifyCredentials(c); 148 - const account = ctx.accountManager.getAccount(session.did); 149 - const { updateHandleForm, refreshHandleForm } = forms; 150 - 151 - // determine current handle parts for form prefill 152 - const currentHandle = account?.handle ?? ''; 153 - const isServiceHandle = ctx.config.identity.serviceHandleDomains.some((d) => currentHandle.endsWith(d)); 154 - const currentDomain = isServiceHandle 155 - ? (ctx.config.identity.serviceHandleDomains.find((d) => currentHandle.endsWith(d)) ?? 'custom') 156 - : 'custom'; 157 - const currentLocalPart = isServiceHandle ? currentHandle.slice(0, -currentDomain.length) : currentHandle; 158 - 159 - const updateHandleError = updateHandleForm.fields.allIssues().at(0); 160 - const refreshHandleError = refreshHandleForm.fields.allIssues().at(0); 161 - 162 - return c.render( 163 - <AccountLayout> 164 - <title>My account - Danaus</title> 165 - 166 - <div class="flex flex-col gap-4"> 167 - <div class="flex h-8 items-center"> 168 - <h3 class="text-base-400 font-medium">Account overview</h3> 169 - </div> 170 - 171 - {updateHandleError && ( 172 - <MessageBar intent="error" layout="singleline"> 173 - <MessageBarBody>{updateHandleError.message}</MessageBarBody> 174 - </MessageBar> 175 - )} 176 - 177 - {refreshHandleError && ( 178 - <MessageBar intent="error" layout="singleline"> 179 - <MessageBarBody>{refreshHandleError.message}</MessageBarBody> 180 - </MessageBar> 181 - )} 182 - 183 - <div class="flex flex-col gap-8"> 184 - <div class="flex flex-col gap-2"> 185 - <h4 class="text-base-300 font-medium text-neutral-foreground-2">Your identity</h4> 186 - 187 - <div class="flex flex-col divide-y divide-neutral-stroke-2 rounded-md bg-neutral-background-1 shadow-4"> 188 - <div class="flex items-center gap-4 px-4 py-3"> 189 - <div class="min-w-0 grow"> 190 - <p class="text-base-300 font-medium wrap-break-word">@{account?.handle}</p> 191 - <p class="text-base-300 text-neutral-foreground-3">Your username on the network</p> 192 - </div> 193 - 194 - <Menu> 195 - <MenuTrigger> 196 - <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 197 - <DotGrid1x3HorizontalOutlined size={16} /> 198 - </button> 199 - </MenuTrigger> 200 - 201 - <MenuPopover> 202 - <MenuList> 203 - <MenuItem command="show-modal" commandfor="change-service-handle-dialog"> 204 - Change handle 205 - </MenuItem> 206 - 207 - <MenuItem command="show-modal" commandfor="refresh-handle-dialog"> 208 - Request refresh 209 - </MenuItem> 210 - </MenuList> 211 - </MenuPopover> 212 - </Menu> 213 - </div> 214 - </div> 215 - </div> 216 - 217 - <div class="flex flex-col gap-2"> 218 - <h4 class="text-base-300 font-medium text-neutral-foreground-2">Account management</h4> 219 - 220 - <div class="flex flex-col divide-y divide-neutral-stroke-2 rounded-md bg-neutral-background-1 shadow-4"> 221 - <div class="flex items-center gap-4 px-4 py-3"> 222 - <div class="min-w-0 grow"> 223 - <p class="text-base-300 font-medium">Data export</p> 224 - <p class="text-base-300 text-neutral-foreground-3">Download your repository and blobs</p> 225 - </div> 226 - 227 - <Button disabled>Export</Button> 228 - </div> 229 - 230 - <div class="flex items-center gap-4 px-4 py-3"> 231 - <div class="min-w-0 grow"> 232 - <p class="text-base-300 font-medium">Deactivate account</p> 233 - <p class="text-base-300 text-neutral-foreground-3">Temporarily disable your account</p> 234 - </div> 235 - 236 - <Button disabled>Deactivate</Button> 237 - </div> 238 - 239 - <div class="flex items-center gap-4 px-4 py-3"> 240 - <div class="min-w-0 grow"> 241 - <p class="text-base-300 font-medium">Delete account</p> 242 - <p class="text-base-300 text-neutral-foreground-3">Permanently delete your account</p> 243 - </div> 244 - 245 - <Button disabled>Delete</Button> 246 - </div> 247 - </div> 248 - </div> 249 - </div> 250 - </div> 251 - 252 - <Dialog id="change-service-handle-dialog"> 253 - <DialogSurface> 254 - <DialogBody> 255 - <DialogTitle>Change handle</DialogTitle> 256 - 257 - <form {...updateHandleForm} class="contents"> 258 - <DialogContent class="flex flex-col gap-4"> 259 - <p class="text-base-300 text-neutral-foreground-3"> 260 - Your handle is your unique identity on the AT Protocol network. 261 - </p> 262 - 263 - <Field label="Handle" required> 264 - <div class="flex gap-2"> 265 - <Input 266 - {...updateHandleForm.fields.handle.as('text')} 267 - value={updateHandleForm.fields.handle.value() || currentLocalPart} 268 - placeholder="alice" 269 - contentBefore={<AtOutlined size={16} />} 270 - class="grow" 271 - /> 272 - 273 - <Select 274 - {...updateHandleForm.fields.domain.as('select')} 275 - value={updateHandleForm.fields.domain.value() || currentDomain} 276 - options={ctx.config.identity.serviceHandleDomains.map((d) => ({ 277 - value: d, 278 - label: d, 279 - }))} 280 - /> 281 - </div> 282 - </Field> 283 - 284 - <div></div> 285 - </DialogContent> 286 - 287 - <DialogActions> 288 - <Button command="show-modal" commandfor="change-custom-handle-dialog"> 289 - Use my own domain 290 - </Button> 291 - 292 - <div class="grow"></div> 293 - 294 - <DialogClose> 295 - <Button>Cancel</Button> 296 - </DialogClose> 297 - 298 - <Button type="submit" variant="primary"> 299 - Change 300 - </Button> 301 - </DialogActions> 302 - </form> 303 - </DialogBody> 304 - </DialogSurface> 305 - </Dialog> 306 - 307 - <Dialog id="refresh-handle-dialog"> 308 - <DialogSurface> 309 - <DialogBody> 310 - <DialogTitle>Request handle refresh</DialogTitle> 311 - 312 - <form {...refreshHandleForm} class="contents"> 313 - <DialogContent> 314 - <p class="text-base-300"> 315 - This will notify the network to re-verify your handle. Use this if apps are marking your 316 - handle as invalid despite being set up correctly. 317 - </p> 318 - </DialogContent> 319 - 320 - <DialogActions> 321 - <DialogClose> 322 - <Button>Cancel</Button> 323 - </DialogClose> 324 - 325 - <Button type="submit" variant="primary"> 326 - Refresh 327 - </Button> 328 - </DialogActions> 329 - </form> 330 - </DialogBody> 331 - </DialogSurface> 332 - </Dialog> 333 - 334 - <Dialog id="change-custom-handle-dialog"> 335 - <DialogSurface> 336 - <DialogBody> 337 - <DialogTitle>Change handle</DialogTitle> 338 - 339 - <form {...updateHandleForm} class="contents"> 340 - <DialogContent class="flex flex-col gap-4"> 341 - <p class="text-base-300 text-neutral-foreground-3"> 342 - Your handle is your unique identity on the AT Protocol network. 343 - </p> 344 - 345 - <Field label="Handle" required> 346 - <Input 347 - {...updateHandleForm.fields.handle.as('text')} 348 - placeholder="alice.com" 349 - contentBefore={<AtOutlined size={16} />} 350 - /> 351 - </Field> 352 - 353 - <input {...updateHandleForm.fields.domain.as('hidden', 'custom')} /> 354 - 355 - <Accordion class="flex flex-col gap-2"> 356 - <AccordionItem name="handle-method" open> 357 - <AccordionHeader>DNS record</AccordionHeader> 358 - <AccordionPanel> 359 - <div class="flex flex-col gap-3"> 360 - <p class="text-base-300 text-neutral-foreground-3"> 361 - Add the following DNS record to your domain: 362 - </p> 363 - 364 - <div class="flex flex-col gap-2 rounded-md bg-neutral-background-3 p-3"> 365 - <div class="flex flex-col gap-0.5"> 366 - <span class="text-base-200 text-neutral-foreground-3">Host</span> 367 - <input 368 - type="text" 369 - readonly 370 - value="_atproto.<your-domain>" 371 - class="font-mono text-base-300 outline-none" 372 - /> 373 - </div> 374 - <div class="flex flex-col gap-0.5"> 375 - <span class="text-base-200 text-neutral-foreground-3">Type</span> 376 - <input 377 - type="text" 378 - readonly 379 - value="TXT" 380 - class="font-mono text-base-300 outline-none" 381 - /> 382 - </div> 383 - <div class="flex flex-col gap-0.5"> 384 - <span class="text-base-200 text-neutral-foreground-3">Value</span> 385 - <input 386 - type="text" 387 - readonly 388 - value={`did=${session.did}`} 389 - class="font-mono text-base-300 outline-none" 390 - /> 391 - </div> 392 - </div> 393 - </div> 394 - </AccordionPanel> 395 - </AccordionItem> 396 - 397 - <AccordionItem name="handle-method"> 398 - <AccordionHeader>HTTP well-known entry</AccordionHeader> 399 - <AccordionPanel> 400 - <div class="flex flex-col gap-3"> 401 - <p class="text-base-300 text-neutral-foreground-3"> 402 - Upload a text file to the following URL: 403 - </p> 404 - 405 - <div class="flex flex-col gap-2 rounded-md bg-neutral-background-3 p-3"> 406 - <div class="flex flex-col gap-0.5"> 407 - <span class="text-base-200 text-neutral-foreground-3">URL</span> 408 - <input 409 - type="text" 410 - readonly 411 - value="https://<your-domain>/.well-known/atproto-did" 412 - class="font-mono text-base-300 outline-none" 413 - /> 414 - </div> 415 - <div class="flex flex-col gap-0.5"> 416 - <span class="text-base-200 text-neutral-foreground-3">Contents</span> 417 - <input 418 - type="text" 419 - readonly 420 - value={session.did} 421 - class="font-mono text-base-300 outline-none" 422 - /> 423 - </div> 424 - </div> 425 - </div> 426 - </AccordionPanel> 427 - </AccordionItem> 428 - </Accordion> 429 - </DialogContent> 430 - 431 - <DialogActions> 432 - <DialogClose> 433 - <Button>Cancel</Button> 434 - </DialogClose> 435 - 436 - <Button type="submit" variant="primary"> 437 - Change 438 - </Button> 439 - </DialogActions> 440 - </form> 441 - </DialogBody> 442 - </DialogSurface> 443 - </Dialog> 444 - </AccountLayout>, 445 - ); 446 - }); 447 - // #endregion 448 - 449 - // #region app passwords route 450 - app.on(['GET', 'POST'], '/app-passwords', (c) => { 451 - const session = verifyCredentials(c); 452 - const did = session.did as Did; 453 - const { createAppPasswordForm, deleteAppPasswordForm } = forms; 454 - 455 - const passwords = ctx.accountManager.listAppPasswords(did); 456 - 457 - const newPasswordResult = createAppPasswordForm.result; 458 - const newPasswordError = createAppPasswordForm.fields.allIssues().at(0); 459 - 460 - return c.render( 461 - <AccountLayout> 462 - <title>App passwords - Danaus</title> 463 - 464 - <div class="flex flex-col gap-4"> 465 - <div class="flex h-8 shrink-0 items-center justify-between"> 466 - <h3 class="text-base-400 font-medium">App passwords</h3> 467 - 468 - <Button commandfor="create-app-password-dialog" command="show-modal" variant="primary"> 469 - <PlusLargeOutlined size={16} /> 470 - New 471 - </Button> 472 - </div> 473 - 474 - {newPasswordResult && ( 475 - <MessageBar intent="success" layout="multiline"> 476 - <MessageBarBody> 477 - <MessageBarTitle>App password created</MessageBarTitle> 478 - 479 - <div class="mt-2 flex flex-col gap-2"> 480 - <code class="rounded-md bg-neutral-background-3 px-2 py-1 font-mono text-base-300"> 481 - {newPasswordResult.secret} 482 - </code> 483 - <p class="text-base-200 text-neutral-foreground-3"> 484 - Copy this password now. You won't be able to see it again. 485 - </p> 486 - </div> 487 - </MessageBarBody> 488 - </MessageBar> 489 - )} 490 - 491 - {newPasswordError && ( 492 - <MessageBar intent="error" layout="singleline"> 493 - <MessageBarBody>{newPasswordError.message}</MessageBarBody> 494 - </MessageBar> 495 - )} 496 - 497 - {/* {passwords.length === 0 ? ( 498 - <p class="py-8 text-center text-base-300 text-neutral-foreground-3">no app passwords yet.</p> 499 - ) : ( 500 - <ul class="divide-y divide-neutral-stroke-2"> 501 - {passwords.map((password) => ( 502 - <li class="flex items-center justify-between gap-4 py-3"> 503 - <div class="flex flex-col"> 504 - <span class="text-base-300 font-medium">{password.name}</span> 505 - <span class="text-base-200 text-neutral-foreground-3"> 506 - {formatAppPasswordPrivilege(password.privilege)} · created{' '} 507 - {password.created_at.toLocaleDateString()} 508 - </span> 509 - </div> 510 - <form {...deleteAppPasswordForm} class="contents"> 511 - <input type="hidden" name="name" value={password.name} /> 512 - <Button type="submit" variant="subtle"> 513 - <TrashCanOutlined size={16} /> 514 - Delete 515 - </Button> 516 - </form> 517 - </li> 518 - ))} 519 - </ul> 520 - )} */} 521 - 522 - <div class="flex flex-col divide-y divide-neutral-stroke-2 rounded-md bg-neutral-background-1 shadow-4"> 523 - {passwords.length === 0 && ( 524 - <div class="flex flex-col gap-1 p-8 text-center"> 525 - <p class="text-base-300 font-medium">No app passwords created</p> 526 - <p class="text-base-300 text-neutral-foreground-3"> 527 - App passwords lets you sign into legacy AT Protocol apps. 528 - </p> 529 - </div> 530 - )} 531 - 532 - {passwords.map((password) => { 533 - let privilege = `Unknown`; 534 - switch (password.privilege) { 535 - case AppPasswordPrivilege.Full: { 536 - privilege = `Full access`; 537 - break; 538 - } 539 - case AppPasswordPrivilege.Privileged: { 540 - privilege = `Privileged access`; 541 - break; 542 - } 543 - case AppPasswordPrivilege.Limited: { 544 - privilege = `Limited access`; 545 - break; 546 - } 547 - } 548 - 549 - return ( 550 - <div class="flex items-center gap-4 px-4 py-3"> 551 - <Key2Outlined size={24} class="shrink-0" /> 552 - 553 - <div class="min-w-0 grow"> 554 - <p class="text-base-300">{password.name}</p> 555 - <p class="text-base-300 text-neutral-foreground-3">{privilege}</p> 556 - </div> 557 - 558 - <Menu> 559 - <MenuTrigger> 560 - <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 561 - <DotGrid1x3HorizontalOutlined size={16} /> 562 - </button> 563 - </MenuTrigger> 564 - 565 - <MenuPopover> 566 - <MenuList> 567 - <Dialog> 568 - <DialogTrigger> 569 - <MenuItem>Delete</MenuItem> 570 - </DialogTrigger> 571 - 572 - <DialogSurface> 573 - <DialogBody> 574 - <DialogTitle>Delete this app password?</DialogTitle> 575 - 576 - <form {...deleteAppPasswordForm} class="contents"> 577 - <DialogContent> 578 - <p class="text-base-300"> 579 - You'll no longer be able to sign in to legacy apps using the{' '} 580 - <strong>{password.name}</strong> app password. 581 - </p> 582 - 583 - <input {...deleteAppPasswordForm.fields.name.as('hidden', password.name)} /> 584 - </DialogContent> 585 - 586 - <DialogActions> 587 - <DialogClose> 588 - <Button>Cancel</Button> 589 - </DialogClose> 590 - 591 - <Button type="submit" variant="primary"> 592 - Delete 593 - </Button> 594 - </DialogActions> 595 - </form> 596 - </DialogBody> 597 - </DialogSurface> 598 - </Dialog> 599 - </MenuList> 600 - </MenuPopover> 601 - </Menu> 602 - </div> 603 - ); 604 - })} 605 - </div> 606 - </div> 607 - 608 - <Dialog id="create-app-password-dialog"> 609 - <DialogSurface> 610 - <DialogBody> 611 - <DialogTitle>Create app password</DialogTitle> 612 - 613 - <form {...createAppPasswordForm} class="contents"> 614 - <DialogContent class="flex flex-col gap-6"> 615 - <Field label="Name" required> 616 - <Input {...createAppPasswordForm.fields.name.as('text')} placeholder="My app" required /> 617 - </Field> 618 - 619 - <Field label="Privilege"> 620 - <Select 621 - {...createAppPasswordForm.fields.privilege.as('select')} 622 - options={[ 623 - { value: 'limited', label: 'Limited - cannot access DMs' }, 624 - { value: 'privileged', label: 'Privileged - can access DMs' }, 625 - { value: 'full', label: 'Full - full account access' }, 626 - ]} 627 - /> 628 - </Field> 629 - </DialogContent> 630 - 631 - <DialogActions> 632 - <Button commandfor="create-app-password-dialog" command="close" variant="outlined"> 633 - Cancel 634 - </Button> 635 - <Button type="submit" variant="primary"> 636 - Create 637 - </Button> 638 - </DialogActions> 639 - </form> 640 - </DialogBody> 641 - </DialogSurface> 642 - </Dialog> 643 - </AccountLayout>, 644 - ); 645 - }); 646 - // #endregion 647 - 648 - // #region security route 649 - app.get('/security', (c) => { 650 - const session = verifyCredentials(c); 651 - const account = ctx.accountManager.getAccount(session.did); 652 - 653 - return c.render( 654 - <AccountLayout> 655 - <title>Security - Danaus</title> 656 - 657 - <div class="flex flex-col gap-4"> 658 - <div class="flex h-8 shrink-0 items-center"> 659 - <h3 class="text-base-400 font-medium">Security</h3> 660 - </div> 661 - 662 - <div class="flex flex-col gap-8"> 663 - <div class="flex flex-col gap-2"> 664 - <h4 class="text-base-300 font-medium text-neutral-foreground-2">Account information</h4> 665 - 666 - <div class="flex flex-col divide-y divide-neutral-stroke-2 rounded-md bg-neutral-background-1 shadow-4"> 667 - <div class="flex items-center gap-4 px-4 py-3"> 668 - <div class="min-w-0 grow"> 669 - <p class="text-base-300 font-medium wrap-break-word">{account?.email}</p> 670 - <p class="text-base-300 text-neutral-foreground-3"> 671 - {account?.email_confirmed_at ? 'Verified' : 'Not verified'} 672 - </p> 673 - </div> 674 - 675 - {!account?.email_confirmed_at && <Button>Verify</Button>} 676 - 677 - <Menu> 678 - <MenuTrigger> 679 - <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 680 - <DotGrid1x3HorizontalOutlined size={16} /> 681 - </button> 682 - </MenuTrigger> 683 - 684 - <MenuPopover> 685 - <MenuList> 686 - <MenuItem>Change email</MenuItem> 687 - </MenuList> 688 - </MenuPopover> 689 - </Menu> 690 - </div> 691 - </div> 692 - </div> 693 - 694 - <div class="flex flex-col gap-2"> 695 - <h4 class="text-base-300 font-medium text-neutral-foreground-2">Ways to prove who you are</h4> 696 - 697 - <div class="flex flex-col divide-y divide-neutral-stroke-2 rounded-md bg-neutral-background-1 shadow-4"> 698 - <div class="flex items-center gap-4 px-4 py-3"> 699 - <PasswordOutlined size={24} class="shrink-0" /> 700 - 701 - <div class="min-w-0 grow"> 702 - <p class="text-base-300 font-medium wrap-break-word">Password</p> 703 - <p class="text-base-300 text-neutral-foreground-3">Last changed yesterday</p> 704 - </div> 705 - 706 - <Menu> 707 - <MenuTrigger> 708 - <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 709 - <DotGrid1x3HorizontalOutlined size={16} /> 710 - </button> 711 - </MenuTrigger> 712 - 713 - <MenuPopover> 714 - <MenuList> 715 - <MenuItem>Change password</MenuItem> 716 - </MenuList> 717 - </MenuPopover> 718 - </Menu> 719 - </div> 720 - 721 - <div class="flex items-center gap-4 px-4 py-3"> 722 - <PhoneOutlined size={24} class="shrink-0" /> 723 - 724 - <div class="min-w-0 grow"> 725 - <p class="text-base-300 font-medium wrap-break-word">Bitwarden</p> 726 - <p class="text-base-300 text-neutral-foreground-3">Authenticator · Added yesterday</p> 727 - </div> 728 - 729 - <Menu> 730 - <MenuTrigger> 731 - <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 732 - <DotGrid1x3HorizontalOutlined size={16} /> 733 - </button> 734 - </MenuTrigger> 735 - 736 - <MenuPopover> 737 - <MenuList> 738 - <MenuItem>Rename</MenuItem> 739 - <MenuDivider /> 740 - <MenuItem>Remove</MenuItem> 741 - </MenuList> 742 - </MenuPopover> 743 - </Menu> 744 - </div> 745 - 746 - <div class="flex items-center gap-4 px-4 py-3"> 747 - <UsbOutlined size={24} class="shrink-0" /> 748 - 749 - <div class="min-w-0 grow"> 750 - <p class="text-base-300 font-medium wrap-break-word">YubiKey 5</p> 751 - <p class="text-base-300 text-neutral-foreground-3">Security key · Added 2 weeks ago</p> 752 - </div> 753 - 754 - <Menu> 755 - <MenuTrigger> 756 - <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 757 - <DotGrid1x3HorizontalOutlined size={16} /> 758 - </button> 759 - </MenuTrigger> 760 - 761 - <MenuPopover> 762 - <MenuList> 763 - <MenuItem>Rename</MenuItem> 764 - <MenuDivider /> 765 - <MenuItem>Remove</MenuItem> 766 - </MenuList> 767 - </MenuPopover> 768 - </Menu> 769 - </div> 770 - 771 - <div class="flex items-center gap-4 px-4 py-3"> 772 - <PasskeysOutlined size={24} class="shrink-0" /> 773 - 774 - <div class="min-w-0 grow"> 775 - <p class="text-base-300 font-medium wrap-break-word">iCloud Keychain</p> 776 - <p class="text-base-300 text-neutral-foreground-3">Passkey · Added last month</p> 777 - </div> 778 - 779 - <Menu> 780 - <MenuTrigger> 781 - <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 782 - <DotGrid1x3HorizontalOutlined size={16} /> 783 - </button> 784 - </MenuTrigger> 785 - 786 - <MenuPopover> 787 - <MenuList> 788 - <MenuItem>Rename</MenuItem> 789 - <MenuDivider /> 790 - <MenuItem>Remove</MenuItem> 791 - </MenuList> 792 - </MenuPopover> 793 - </Menu> 794 - </div> 795 - 796 - <button class="flex items-center gap-4 bg-subtle-background px-4 py-3 text-left outline-2 -outline-offset-2 outline-transparent transition select-none first:rounded-t-md last:rounded-b-md hover:bg-subtle-background-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active"> 797 - <div class="grid h-6 w-6 shrink-0 place-items-center"> 798 - <PlusLargeOutlined size={16} /> 799 - </div> 800 - 801 - <div class="min-w-0 grow"> 802 - <p class="text-base-300">Add another way to sign in</p> 803 - </div> 804 - </button> 805 - </div> 806 - </div> 807 - </div> 808 - </div> 809 - </AccountLayout>, 810 - ); 811 - }); 812 - // #endregion 813 - 814 - return app; 815 - }; 816 - 817 - // #region account layout component 818 - interface AccountLayoutProps { 819 - children?: unknown; 820 - } 821 - 822 - const AccountLayout = (props: AccountLayoutProps) => { 823 - return ( 824 - <div class="flex flex-col gap-4 p-4 sm:p-16 sm:pt-24 lg:grid lg:grid-cols-[280px_minmax(0,640px)] lg:justify-center"> 825 - <aside class="-ml-2 flex flex-col gap-4 sm:ml-0"> 826 - <div class="flex h-8 shrink-0 items-center pl-4"> 827 - <h2 class="text-base-400 font-medium">Account</h2> 828 - </div> 829 - 830 - <div class="flex flex-col gap-px"> 831 - <AsideItem href="/account" exact icon={<PersonOutlined size={20} />}> 832 - Overview 833 - </AsideItem> 834 - 835 - <AsideItem href="/account/app-passwords" icon={<Key2Outlined size={20} />}> 836 - App passwords 837 - </AsideItem> 838 - 839 - <AsideItem href="/account/security" icon={<ShieldOutlined size={20} />}> 840 - Security 841 - </AsideItem> 842 - </div> 843 - </aside> 844 - 845 - <hr class="border-neutral-stroke-1 sm:hidden" /> 846 - 847 - <main>{props.children}</main> 848 - </div> 849 - ); 850 - }; 851 - // #endregion
+7 -6
packages/danaus/src/web/admin/components/aside-item.tsx packages/danaus/src/web/components/aside-item.tsx
··· 1 + import { getContext } from '@oomfware/fetch-router/middlewares/async-context'; 2 + import type { JSXNode } from '@oomfware/jsx'; 3 + 1 4 import { cva } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 - import { useRequestContext } from 'hono/jsx-renderer'; 4 5 5 6 const root = cva({ 6 7 base: [ ··· 22 23 href: string; 23 24 /** whether to match the path exactly (default: false) */ 24 25 exact?: boolean; 25 - icon?: Child; 26 - children?: Child; 26 + icon?: JSXNode; 27 + children?: JSXNode; 27 28 } 28 29 29 30 /** ··· 35 36 const AsideItem = (props: AsideItemProps) => { 36 37 const { href, exact = false, icon, children } = props; 37 38 38 - const c = useRequestContext(); 39 - const currentPath = c.req.path; 39 + const { url } = getContext(); 40 + const currentPath = url.pathname; 40 41 const isActive = exact ? currentPath === href : currentPath.startsWith(href); 41 42 42 43 return (
+61 -57
packages/danaus/src/web/admin/forms.ts
··· 1 1 import type { Handle } from '@atcute/lexicons'; 2 2 import { XRPCError } from '@atcute/xrpc-server'; 3 + import { redirect } from '@oomfware/fetch-router'; 4 + import { form, invalid } from '@oomfware/forms'; 3 5 4 6 import * as v from 'valibot'; 5 7 6 8 import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH } from '#app/accounts/passwords.ts'; 7 9 import { provisionAccount } from '#app/api/local.danaus/account.createAccount.ts'; 8 - import type { AppContext } from '#app/context.ts'; 9 10 10 - import { form, invalid, redirect } from '../forms/index.ts'; 11 + import { getAppContext } from '../middlewares/app-context.ts'; 11 12 12 - export const createAdminForms = (ctx: AppContext) => { 13 - const createAccountForm = form( 14 - v.object({ 15 - handle: v.pipe( 16 - v.string(), 17 - v.minLength(1, `Handle is required`), 18 - v.maxLength(63, `Handle is too long`), 19 - v.regex(/^[a-zA-Z0-9][a-zA-Z0-9-]*$/, `Invalid handle`), 20 - ), 21 - domain: v.picklist(ctx.config.identity.serviceHandleDomains), 22 - email: v.pipe(v.string(), v.minLength(1, `Email is required`), v.email(`Invalid email`)), 23 - password: v.pipe( 24 - v.string(), 25 - v.minLength(1, `Password is required`), 26 - v.minLength(MIN_PASSWORD_LENGTH, `Password is too short`), 27 - v.maxLength(MAX_PASSWORD_LENGTH, `Password is too long`), 28 - ), 29 - }), 30 - async (data, issue) => { 31 - const handle = `${data.handle}${data.domain}` as Handle; 13 + export const createAccountForm = form( 14 + v.object({ 15 + handle: v.pipe( 16 + v.string(), 17 + v.minLength(1, `Handle is required`), 18 + v.maxLength(63, `Handle is too long`), 19 + v.regex(/^[a-zA-Z0-9][a-zA-Z0-9-]*$/, `Invalid handle`), 20 + ), 21 + domain: v.string(), 22 + email: v.pipe(v.string(), v.minLength(1, `Email is required`), v.email(`Invalid email`)), 23 + password: v.pipe( 24 + v.string(), 25 + v.minLength(1, `Password is required`), 26 + v.minLength(MIN_PASSWORD_LENGTH, `Password is too short`), 27 + v.maxLength(MAX_PASSWORD_LENGTH, `Password is too long`), 28 + ), 29 + }), 30 + async (data, issue) => { 31 + const ctx = getAppContext(); 32 32 33 - try { 34 - await provisionAccount(ctx, { 35 - handle, 36 - email: data.email, 37 - password: data.password, 38 - }); 33 + // validate domain against config 34 + if (!ctx.config.identity.serviceHandleDomains.includes(data.domain)) { 35 + invalid(issue.domain(`Invalid domain`)); 36 + } 39 37 40 - redirect(302, '/admin/accounts'); 41 - } catch (err) { 42 - if (err instanceof XRPCError && err.status === 400) { 43 - switch (err.error) { 44 - case 'InvalidHandle': 45 - case 'UnsupportedDomain': { 46 - invalid(issue.handle(`Invalid handle`)); 47 - } 48 - case 'HandleTaken': { 49 - invalid(issue.handle(`Handle is already taken`)); 50 - } 51 - case 'InvalidEmail': { 52 - invalid(issue.email(`Invalid email`)); 53 - } 54 - case 'EmailTaken': { 55 - invalid(issue.email(`Email is already taken`)); 56 - } 57 - case 'InvalidPassword': { 58 - invalid(issue.password(`Invalid password`)); 59 - } 60 - default: { 61 - invalid(`Something went wrong`); 62 - } 38 + const handle = `${data.handle}${data.domain}` as Handle; 39 + 40 + try { 41 + await provisionAccount(ctx, { 42 + handle, 43 + email: data.email, 44 + password: data.password, 45 + }); 46 + 47 + redirect('/admin/accounts'); 48 + } catch (err) { 49 + if (err instanceof XRPCError && err.status === 400) { 50 + switch (err.error) { 51 + case 'InvalidHandle': 52 + case 'UnsupportedDomain': { 53 + invalid(issue.handle(`Invalid handle`)); 54 + } 55 + case 'HandleTaken': { 56 + invalid(issue.handle(`Handle is already taken`)); 57 + } 58 + case 'InvalidEmail': { 59 + invalid(issue.email(`Invalid email`)); 60 + } 61 + case 'EmailTaken': { 62 + invalid(issue.email(`Email is already taken`)); 63 + } 64 + case 'InvalidPassword': { 65 + invalid(issue.password(`Invalid password`)); 66 + } 67 + default: { 68 + invalid(`Something went wrong`); 63 69 } 64 70 } 65 - 66 - throw err; 67 71 } 68 - }, 69 - ); 70 72 71 - return { createAccountForm }; 72 - }; 73 + throw err; 74 + } 75 + }, 76 + );
-286
packages/danaus/src/web/admin/index.tsx
··· 1 - import { Hono } from 'hono'; 2 - import { jsxRenderer } from 'hono/jsx-renderer'; 3 - 4 - import { parseBasicAuth } from '#app/auth/verifier.ts'; 5 - import type { AppContext } from '#app/context.ts'; 6 - 7 - import { IdProvider } from '../components/id.tsx'; 8 - import { registerForms } from '../forms/index.ts'; 9 - import Group1Outlined from '../icons/central/group-1-outlined.tsx'; 10 - import HomeOpenOutlined from '../icons/central/home-open-outlined.tsx'; 11 - import MagnifyingGlassOutlined from '../icons/central/magnifying-glass-outlined.tsx'; 12 - import PlusLargeOutlined from '../icons/central/plus-large-outlined.tsx'; 13 - import Button from '../primitives/button.tsx'; 14 - import Field from '../primitives/field.tsx'; 15 - import Input from '../primitives/input.tsx'; 16 - import Select from '../primitives/select.tsx'; 17 - 18 - import AsideItem from './components/aside-item.tsx'; 19 - import StatCard from './components/stat-card.tsx'; 20 - import { createAdminForms } from './forms.ts'; 21 - 22 - const REALM = `admin`; 23 - 24 - export const createAdminApp = (ctx: AppContext) => { 25 - const app = new Hono(); 26 - const main = new Hono(); 27 - 28 - const adminPassword = ctx.config.secrets.adminPassword; 29 - if (adminPassword === null) { 30 - app.use(async (c, _next) => { 31 - return c.text(`Administration UI is disabled`); 32 - }); 33 - 34 - return app; 35 - } 36 - 37 - app.use(async (c, next) => { 38 - const auth = parseBasicAuth(c.req.raw); 39 - if (auth === null || auth.password !== adminPassword) { 40 - return c.text(`Unauthorized`, 401, { 41 - 'www-authenticate': `Basic realm="${REALM}", charset="UTF-8"`, 42 - }); 43 - } 44 - 45 - await next(); 46 - }); 47 - 48 - const forms = createAdminForms(ctx); 49 - app.use(registerForms(forms)); 50 - 51 - app.use( 52 - jsxRenderer(({ children }) => { 53 - return ( 54 - <IdProvider> 55 - <html lang="en"> 56 - <head> 57 - <meta charset="utf-8" /> 58 - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 59 - <link rel="stylesheet" href="/assets/style.css" /> 60 - </head> 61 - 62 - <body> 63 - <div class="flex min-h-dvh flex-col">{children}</div> 64 - </body> 65 - </html> 66 - </IdProvider> 67 - ); 68 - }), 69 - ); 70 - 71 - main.use( 72 - jsxRenderer(({ children, Layout: Html }) => { 73 - return ( 74 - <Html> 75 - <div class="flex flex-col gap-4 p-4 sm:p-16 sm:pt-24 lg:grid lg:grid-cols-[280px_minmax(0,640px)] lg:justify-center"> 76 - <aside class="-ml-2 flex flex-col gap-2 sm:ml-0"> 77 - <h2 class="pb-2 pl-4 text-base-400 font-medium">PDS administration</h2> 78 - 79 - <div class="flex flex-col gap-px"> 80 - <AsideItem href="/admin" exact icon={<HomeOpenOutlined size={20} />}> 81 - Home 82 - </AsideItem> 83 - 84 - <AsideItem href="/admin/accounts" icon={<Group1Outlined size={20} />}> 85 - Accounts 86 - </AsideItem> 87 - </div> 88 - </aside> 89 - 90 - <hr class="border-neutral-stroke-1 sm:hidden" /> 91 - 92 - <main>{children}</main> 93 - </div> 94 - </Html> 95 - ); 96 - }), 97 - ); 98 - 99 - // #region home route 100 - main.get('/', (c) => { 101 - const accountStats = ctx.accountManager.getAccountStats(); 102 - const inviteCodeStats = ctx.accountManager.getInviteCodeStats(); 103 - const sequencerStats = ctx.sequencer.getStats(); 104 - 105 - return c.render( 106 - <> 107 - <title>Home - Danaus admin</title> 108 - 109 - <div class="flex flex-col gap-4"> 110 - <h3 class="text-base-400 font-medium">Home</h3> 111 - 112 - <div class="flex flex-col gap-6"> 113 - <div class="flex flex-col gap-2"> 114 - <h4 class="text-base-300 font-medium text-neutral-foreground-2">Accounts</h4> 115 - <div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5"> 116 - <StatCard label="Total" value={accountStats.total} /> 117 - <StatCard label="Active" value={accountStats.active} /> 118 - <StatCard label="Deactivated" value={accountStats.deactivated} /> 119 - <StatCard label="Taken down" value={accountStats.takendown} /> 120 - <StatCard label="Delete scheduled" value={accountStats.deleteScheduled} /> 121 - </div> 122 - </div> 123 - 124 - <div class="flex flex-col gap-2"> 125 - <h4 class="text-base-300 font-medium text-neutral-foreground-2">Invite codes</h4> 126 - <div class="grid grid-cols-2 gap-3 sm:grid-cols-4"> 127 - <StatCard label="Total" value={inviteCodeStats.total} /> 128 - <StatCard label="Available" value={inviteCodeStats.available} /> 129 - <StatCard label="Used" value={inviteCodeStats.used} /> 130 - <StatCard label="Disabled" value={inviteCodeStats.disabled} /> 131 - </div> 132 - </div> 133 - 134 - <div class="flex flex-col gap-2"> 135 - <h4 class="text-base-300 font-medium text-neutral-foreground-2">Sequencer</h4> 136 - <div class="grid grid-cols-2 gap-3 sm:grid-cols-3"> 137 - <StatCard label="Last seq" value={sequencerStats.lastSeq} /> 138 - <StatCard label="Total events" value={sequencerStats.totalEvents} /> 139 - <StatCard label="Invalidated events" value={sequencerStats.invalidatedEvents} /> 140 - </div> 141 - </div> 142 - </div> 143 - </div> 144 - </>, 145 - ); 146 - }); 147 - // #endregion 148 - 149 - // #region accounts routes 150 - main.get('/accounts', (c) => { 151 - const query = c.req.query('q') ?? ''; 152 - const cursor = c.req.query('cursor'); 153 - 154 - const { accounts, cursor: nextCursor } = ctx.accountManager.listAccounts({ 155 - query: query || undefined, 156 - cursor, 157 - limit: 50, 158 - }); 159 - 160 - const buildHref = (nextCursor: string) => { 161 - const params = new URLSearchParams(); 162 - if (query) { 163 - params.set('q', query); 164 - } 165 - params.set('cursor', nextCursor); 166 - return `/admin/accounts?${params.toString()}`; 167 - }; 168 - 169 - return c.render( 170 - <> 171 - <title>Accounts - Danaus admin</title> 172 - 173 - <div class="flex flex-col gap-4"> 174 - <h3 class="text-base-400 font-medium">Accounts</h3> 175 - 176 - <div class="flex gap-2"> 177 - <form method="get" action="/admin/accounts" class="contents"> 178 - <Input 179 - type="search" 180 - name="q" 181 - value={query} 182 - placeholder="Search by handle or email..." 183 - contentBefore={<MagnifyingGlassOutlined size={16} />} 184 - class="grow" 185 - /> 186 - </form> 187 - 188 - <Button label="New account" href="/admin/accounts/new" variant="primary"> 189 - <PlusLargeOutlined size={16} /> 190 - New 191 - </Button> 192 - </div> 193 - 194 - <div class="flex flex-col"> 195 - {accounts.length === 0 ? ( 196 - <p class="py-8 text-center text-base-300 text-neutral-foreground-3"> 197 - {query ? 'No accounts found matching your search.' : 'No accounts yet.'} 198 - </p> 199 - ) : ( 200 - <ul class="divide-y divide-neutral-stroke-2"> 201 - {accounts.map((account) => ( 202 - <li class="flex items-center justify-between gap-4 py-3"> 203 - <div class="flex min-w-0 flex-col"> 204 - <span class="truncate text-base-300 font-medium">@{account.handle}</span> 205 - <span class="truncate text-base-200 text-neutral-foreground-3">{account.email}</span> 206 - </div> 207 - <div class="flex shrink-0 gap-2 text-base-200 text-neutral-foreground-3"> 208 - {account.deactivated_at && ( 209 - <span class="rounded-md bg-neutral-background-3 px-1.5 py-0.5 text-neutral-foreground-2"> 210 - deactivated 211 - </span> 212 - )} 213 - {account.takedown_ref && ( 214 - <span class="rounded-md bg-status-danger-background-1 px-1.5 py-0.5 text-status-danger-foreground-1"> 215 - taken down 216 - </span> 217 - )} 218 - </div> 219 - </li> 220 - ))} 221 - </ul> 222 - )} 223 - </div> 224 - 225 - {nextCursor && ( 226 - <div class="flex justify-end"> 227 - <Button href={buildHref(nextCursor)} variant="outlined"> 228 - Next page 229 - </Button> 230 - </div> 231 - )} 232 - </div> 233 - </>, 234 - ); 235 - }); 236 - 237 - main.on(['GET', 'POST'], '/accounts/new', (c) => { 238 - const domains = ctx.config.identity.serviceHandleDomains; 239 - const domainOptions = domains.map((d) => ({ value: d, label: d })); 240 - 241 - const { createAccountForm } = forms; 242 - const { fields } = createAccountForm; 243 - 244 - return c.render( 245 - <> 246 - <title>New account - Danaus admin</title> 247 - 248 - <div class="flex flex-col gap-4"> 249 - <h3 class="text-base-400 font-medium">New account</h3> 250 - 251 - <form {...createAccountForm} class="flex max-w-96 flex-col gap-6"> 252 - <Field label="Handle" required validationMessageText={fields.handle.issues()[0]?.message}> 253 - <div class="flex gap-2"> 254 - <Input {...fields.handle.as('text')} placeholder="alice" required class="grow" /> 255 - 256 - <Select {...fields.domain.as('select')} options={domainOptions} /> 257 - </div> 258 - </Field> 259 - 260 - <Field label="Email" required validationMessageText={fields.email.issues()[0]?.message}> 261 - <Input {...fields.email.as('email')} placeholder="alice@example.com" required /> 262 - </Field> 263 - 264 - <Field label="Password" required validationMessageText={fields.password.issues()[0]?.message}> 265 - <Input {...fields.password.as('password')} required /> 266 - </Field> 267 - 268 - <div class="flex gap-3 pt-2"> 269 - <Button type="submit" variant="primary"> 270 - Create account 271 - </Button> 272 - <Button href="/admin/accounts" variant="outlined"> 273 - Cancel 274 - </Button> 275 - </div> 276 - </form> 277 - </div> 278 - </>, 279 - ); 280 - }); 281 - // #endregion 282 - 283 - app.route('/', main); 284 - 285 - return app; 286 - };
-18
packages/danaus/src/web/app.ts
··· 1 - import { Hono } from 'hono'; 2 - 3 - import type { AppContext } from '../context.ts'; 4 - 5 - import { createAccountApp } from './account/index.tsx'; 6 - import { createAdminApp } from './admin/index.tsx'; 7 - import { createOAuthApp } from './oauth/index.tsx'; 8 - 9 - export const createWebApp = (ctx: AppContext): Hono => { 10 - const app = new Hono(); 11 - 12 - app.get('/', (c) => c.text(`This is an AT Protocol personal data server.`)); 13 - app.route('/admin', createAdminApp(ctx)); 14 - app.route('/account', createAccountApp(ctx)); 15 - app.route('/oauth', createOAuthApp(ctx)); 16 - 17 - return app; 18 - };
+3 -3
packages/danaus/src/web/components/id.tsx
··· 1 - import { createContext, useContext, type Child } from 'hono/jsx'; 1 + import { createContext, use, type JSXNode } from '@oomfware/jsx'; 2 2 3 3 export interface IdContextValue { 4 4 count: number; ··· 7 7 export const IdContext = createContext<IdContextValue | null>(null); 8 8 9 9 export const useId = (): string => { 10 - const context = useContext(IdContext); 10 + const context = use(IdContext); 11 11 if (context === null) { 12 12 throw new Error(`expected useId() to be used under <IdProvider>`); 13 13 } ··· 16 16 }; 17 17 18 18 export interface IdProviderProps { 19 - children?: Child; 19 + children?: JSXNode; 20 20 } 21 21 22 22 export const IdProvider = (props: IdProviderProps) => {
+674
packages/danaus/src/web/controllers/account.tsx
··· 1 + import type { Did } from '@atcute/lexicons'; 2 + import type { Controller } from '@oomfware/fetch-router'; 3 + import { forms } from '@oomfware/forms'; 4 + import { render } from '@oomfware/jsx'; 5 + 6 + import { AppPasswordPrivilege } from '#app/accounts/db/schema.ts'; 7 + 8 + import { 9 + createAppPasswordForm, 10 + deleteAppPasswordForm, 11 + refreshHandleForm, 12 + updateHandleForm, 13 + } from '../account/forms.ts'; 14 + import AtOutlined from '../icons/central/at-outlined.tsx'; 15 + import DotGrid1x3HorizontalOutlined from '../icons/central/dot-grid-1x3-horizontal-outlined.tsx'; 16 + import Key2Outlined from '../icons/central/key-2-outlined.tsx'; 17 + import PasskeysOutlined from '../icons/central/passkeys-outlined.tsx'; 18 + import PasswordOutlined from '../icons/central/password-outlined.tsx'; 19 + import PhoneOutlined from '../icons/central/phone-outlined.tsx'; 20 + import PlusLargeOutlined from '../icons/central/plus-large-outlined.tsx'; 21 + import TrashCanOutlined from '../icons/central/trash-can-outlined.tsx'; 22 + import UsbOutlined from '../icons/central/usb-outlined.tsx'; 23 + import { AccountLayout } from '../layouts/account.tsx'; 24 + import { getAppContext } from '../middlewares/app-context.ts'; 25 + import { getSession, requireSession } from '../middlewares/session.ts'; 26 + import AccordionHeader from '../primitives/accordion-header.tsx'; 27 + import AccordionItem from '../primitives/accordion-item.tsx'; 28 + import AccordionPanel from '../primitives/accordion-panel.tsx'; 29 + import Accordion from '../primitives/accordion.tsx'; 30 + import Button from '../primitives/button.tsx'; 31 + import DialogActions from '../primitives/dialog-actions.tsx'; 32 + import DialogBody from '../primitives/dialog-body.tsx'; 33 + import DialogClose from '../primitives/dialog-close.tsx'; 34 + import DialogContent from '../primitives/dialog-content.tsx'; 35 + import DialogSurface from '../primitives/dialog-surface.tsx'; 36 + import DialogTitle from '../primitives/dialog-title.tsx'; 37 + import Dialog from '../primitives/dialog.tsx'; 38 + import Field from '../primitives/field.tsx'; 39 + import Input from '../primitives/input.tsx'; 40 + import MenuDivider from '../primitives/menu-divider.tsx'; 41 + import MenuItem from '../primitives/menu-item.tsx'; 42 + import MenuList from '../primitives/menu-list.tsx'; 43 + import MenuPopover from '../primitives/menu-popover.tsx'; 44 + import MenuTrigger from '../primitives/menu-trigger.tsx'; 45 + import Menu from '../primitives/menu.tsx'; 46 + import MessageBarBody from '../primitives/message-bar-body.tsx'; 47 + import MessageBarTitle from '../primitives/message-bar-title.tsx'; 48 + import MessageBar from '../primitives/message-bar.tsx'; 49 + import Select from '../primitives/select.tsx'; 50 + import type { routes } from '../routes.ts'; 51 + 52 + export default { 53 + middleware: [ 54 + requireSession(), 55 + forms({ 56 + updateHandleForm, 57 + refreshHandleForm, 58 + createAppPasswordForm, 59 + deleteAppPasswordForm, 60 + }), 61 + ], 62 + actions: { 63 + overview() { 64 + const ctx = getAppContext(); 65 + const session = getSession(); 66 + const account = ctx.accountManager.getAccount(session.did); 67 + 68 + // determine current handle parts for form prefill 69 + const currentHandle = account?.handle ?? ''; 70 + const isServiceHandle = ctx.config.identity.serviceHandleDomains.some((d) => currentHandle.endsWith(d)); 71 + const currentDomain = isServiceHandle 72 + ? (ctx.config.identity.serviceHandleDomains.find((d) => currentHandle.endsWith(d)) ?? 'custom') 73 + : 'custom'; 74 + const currentLocalPart = isServiceHandle 75 + ? currentHandle.slice(0, -currentDomain.length) 76 + : currentHandle; 77 + 78 + const updateHandleError = updateHandleForm.fields.allIssues()?.[0]; 79 + const refreshHandleError = refreshHandleForm.fields.allIssues()?.[0]; 80 + 81 + return render( 82 + <AccountLayout> 83 + <title>My account - Danaus</title> 84 + 85 + <div class="flex flex-col gap-4"> 86 + <div class="flex h-8 items-center"> 87 + <h3 class="text-base-400 font-medium">Account overview</h3> 88 + </div> 89 + 90 + {updateHandleError && ( 91 + <MessageBar intent="error" layout="singleline"> 92 + <MessageBarBody>{updateHandleError.message}</MessageBarBody> 93 + </MessageBar> 94 + )} 95 + 96 + {refreshHandleError && ( 97 + <MessageBar intent="error" layout="singleline"> 98 + <MessageBarBody>{refreshHandleError.message}</MessageBarBody> 99 + </MessageBar> 100 + )} 101 + 102 + <div class="flex flex-col gap-8"> 103 + <div class="flex flex-col gap-2"> 104 + <h4 class="text-base-300 font-medium text-neutral-foreground-2">Your identity</h4> 105 + 106 + <div class="flex flex-col divide-y divide-neutral-stroke-2 rounded-md bg-neutral-background-1 shadow-4"> 107 + <div class="flex items-center gap-4 px-4 py-3"> 108 + <div class="min-w-0 grow"> 109 + <p class="text-base-300 font-medium wrap-break-word">@{account?.handle}</p> 110 + <p class="text-base-300 text-neutral-foreground-3">Your username on the network</p> 111 + </div> 112 + 113 + <Menu> 114 + <MenuTrigger> 115 + <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 116 + <DotGrid1x3HorizontalOutlined size={16} /> 117 + </button> 118 + </MenuTrigger> 119 + 120 + <MenuPopover> 121 + <MenuList> 122 + <MenuItem command="show-modal" commandfor="change-service-handle-dialog"> 123 + Change handle 124 + </MenuItem> 125 + 126 + <MenuItem command="show-modal" commandfor="refresh-handle-dialog"> 127 + Request refresh 128 + </MenuItem> 129 + </MenuList> 130 + </MenuPopover> 131 + </Menu> 132 + </div> 133 + </div> 134 + </div> 135 + 136 + <div class="flex flex-col gap-2"> 137 + <h4 class="text-base-300 font-medium text-neutral-foreground-2">Account management</h4> 138 + 139 + <div class="flex flex-col divide-y divide-neutral-stroke-2 rounded-md bg-neutral-background-1 shadow-4"> 140 + <div class="flex items-center gap-4 px-4 py-3"> 141 + <div class="min-w-0 grow"> 142 + <p class="text-base-300 font-medium">Data export</p> 143 + <p class="text-base-300 text-neutral-foreground-3"> 144 + Download your repository and blobs 145 + </p> 146 + </div> 147 + 148 + <Button disabled>Export</Button> 149 + </div> 150 + 151 + <div class="flex items-center gap-4 px-4 py-3"> 152 + <div class="min-w-0 grow"> 153 + <p class="text-base-300 font-medium">Deactivate account</p> 154 + <p class="text-base-300 text-neutral-foreground-3">Temporarily disable your account</p> 155 + </div> 156 + 157 + <Button disabled>Deactivate</Button> 158 + </div> 159 + 160 + <div class="flex items-center gap-4 px-4 py-3"> 161 + <div class="min-w-0 grow"> 162 + <p class="text-base-300 font-medium">Delete account</p> 163 + <p class="text-base-300 text-neutral-foreground-3">Permanently delete your account</p> 164 + </div> 165 + 166 + <Button disabled>Delete</Button> 167 + </div> 168 + </div> 169 + </div> 170 + </div> 171 + </div> 172 + 173 + <Dialog id="change-service-handle-dialog"> 174 + <DialogSurface> 175 + <DialogBody> 176 + <DialogTitle>Change handle</DialogTitle> 177 + 178 + <form {...updateHandleForm} class="contents"> 179 + <DialogContent class="flex flex-col gap-4"> 180 + <p class="text-base-300 text-neutral-foreground-3"> 181 + Your handle is your unique identity on the AT Protocol network. 182 + </p> 183 + 184 + <Field label="Handle" required> 185 + <div class="flex gap-2"> 186 + <Input 187 + {...updateHandleForm.fields.handle.as('text')} 188 + value={updateHandleForm.fields.handle.value() || currentLocalPart} 189 + placeholder="alice" 190 + contentBefore={<AtOutlined size={16} />} 191 + class="grow" 192 + /> 193 + 194 + <Select 195 + {...updateHandleForm.fields.domain.as('select')} 196 + value={updateHandleForm.fields.domain.value() || currentDomain} 197 + options={ctx.config.identity.serviceHandleDomains.map((d) => ({ 198 + value: d, 199 + label: d, 200 + }))} 201 + /> 202 + </div> 203 + </Field> 204 + </DialogContent> 205 + 206 + <DialogActions> 207 + <Button command="show-modal" commandfor="change-custom-handle-dialog"> 208 + Use my own domain 209 + </Button> 210 + 211 + <div class="grow"></div> 212 + 213 + <DialogClose> 214 + <Button>Cancel</Button> 215 + </DialogClose> 216 + 217 + <Button type="submit" variant="primary"> 218 + Change 219 + </Button> 220 + </DialogActions> 221 + </form> 222 + </DialogBody> 223 + </DialogSurface> 224 + </Dialog> 225 + 226 + <Dialog id="refresh-handle-dialog"> 227 + <DialogSurface> 228 + <DialogBody> 229 + <DialogTitle>Request handle refresh</DialogTitle> 230 + 231 + <form {...refreshHandleForm} class="contents"> 232 + <DialogContent> 233 + <p class="text-base-300"> 234 + This will notify the network to re-verify your handle. Use this if apps are marking your 235 + handle as invalid despite being set up correctly. 236 + </p> 237 + </DialogContent> 238 + 239 + <DialogActions> 240 + <DialogClose> 241 + <Button>Cancel</Button> 242 + </DialogClose> 243 + 244 + <Button type="submit" variant="primary"> 245 + Refresh 246 + </Button> 247 + </DialogActions> 248 + </form> 249 + </DialogBody> 250 + </DialogSurface> 251 + </Dialog> 252 + 253 + <Dialog id="change-custom-handle-dialog"> 254 + <DialogSurface> 255 + <DialogBody> 256 + <DialogTitle>Change handle</DialogTitle> 257 + 258 + <form {...updateHandleForm} class="contents"> 259 + <DialogContent class="flex flex-col gap-4"> 260 + <p class="text-base-300 text-neutral-foreground-3"> 261 + Your handle is your unique identity on the AT Protocol network. 262 + </p> 263 + 264 + <Field label="Handle" required> 265 + <Input 266 + {...updateHandleForm.fields.handle.as('text')} 267 + placeholder="alice.com" 268 + contentBefore={<AtOutlined size={16} />} 269 + /> 270 + </Field> 271 + 272 + <input {...updateHandleForm.fields.domain.as('hidden', 'custom')} /> 273 + 274 + <Accordion class="flex flex-col gap-2"> 275 + <AccordionItem name="handle-method" open> 276 + <AccordionHeader>DNS record</AccordionHeader> 277 + <AccordionPanel> 278 + <div class="flex flex-col gap-3"> 279 + <p class="text-base-300 text-neutral-foreground-3"> 280 + Add the following DNS record to your domain: 281 + </p> 282 + 283 + <div class="flex flex-col gap-2 rounded-md bg-neutral-background-3 p-3"> 284 + <div class="flex flex-col gap-0.5"> 285 + <span class="text-base-200 text-neutral-foreground-3">Host</span> 286 + <input 287 + type="text" 288 + readonly 289 + value="_atproto.<your-domain>" 290 + class="font-mono text-base-300 outline-none" 291 + /> 292 + </div> 293 + <div class="flex flex-col gap-0.5"> 294 + <span class="text-base-200 text-neutral-foreground-3">Type</span> 295 + <input 296 + type="text" 297 + readonly 298 + value="TXT" 299 + class="font-mono text-base-300 outline-none" 300 + /> 301 + </div> 302 + <div class="flex flex-col gap-0.5"> 303 + <span class="text-base-200 text-neutral-foreground-3">Value</span> 304 + <input 305 + type="text" 306 + readonly 307 + value={`did=${session.did}`} 308 + class="font-mono text-base-300 outline-none" 309 + /> 310 + </div> 311 + </div> 312 + </div> 313 + </AccordionPanel> 314 + </AccordionItem> 315 + 316 + <AccordionItem name="handle-method"> 317 + <AccordionHeader>HTTP well-known entry</AccordionHeader> 318 + <AccordionPanel> 319 + <div class="flex flex-col gap-3"> 320 + <p class="text-base-300 text-neutral-foreground-3"> 321 + Upload a text file to the following URL: 322 + </p> 323 + 324 + <div class="flex flex-col gap-2 rounded-md bg-neutral-background-3 p-3"> 325 + <div class="flex flex-col gap-0.5"> 326 + <span class="text-base-200 text-neutral-foreground-3">URL</span> 327 + <input 328 + type="text" 329 + readonly 330 + value="https://<your-domain>/.well-known/atproto-did" 331 + class="font-mono text-base-300 outline-none" 332 + /> 333 + </div> 334 + <div class="flex flex-col gap-0.5"> 335 + <span class="text-base-200 text-neutral-foreground-3">Contents</span> 336 + <input 337 + type="text" 338 + readonly 339 + value={session.did} 340 + class="font-mono text-base-300 outline-none" 341 + /> 342 + </div> 343 + </div> 344 + </div> 345 + </AccordionPanel> 346 + </AccordionItem> 347 + </Accordion> 348 + </DialogContent> 349 + 350 + <DialogActions> 351 + <DialogClose> 352 + <Button>Cancel</Button> 353 + </DialogClose> 354 + 355 + <Button type="submit" variant="primary"> 356 + Change 357 + </Button> 358 + </DialogActions> 359 + </form> 360 + </DialogBody> 361 + </DialogSurface> 362 + </Dialog> 363 + </AccountLayout>, 364 + ); 365 + }, 366 + 367 + appPasswords() { 368 + const ctx = getAppContext(); 369 + const session = getSession(); 370 + const did = session.did as Did; 371 + 372 + const passwords = ctx.accountManager.listAppPasswords(did); 373 + 374 + const newPasswordResult = createAppPasswordForm.result; 375 + const newPasswordError = createAppPasswordForm.fields.allIssues()?.[0]; 376 + 377 + return render( 378 + <AccountLayout> 379 + <title>App passwords - Danaus</title> 380 + 381 + <div class="flex flex-col gap-4"> 382 + <div class="flex h-8 shrink-0 items-center justify-between"> 383 + <h3 class="text-base-400 font-medium">App passwords</h3> 384 + 385 + <Button commandfor="create-app-password-dialog" command="show-modal" variant="primary"> 386 + <PlusLargeOutlined size={16} /> 387 + New 388 + </Button> 389 + </div> 390 + 391 + {newPasswordResult && ( 392 + <MessageBar intent="success" layout="multiline"> 393 + <MessageBarBody> 394 + <MessageBarTitle>App password created</MessageBarTitle> 395 + 396 + <div class="mt-2 flex flex-col gap-2"> 397 + <code class="rounded-md bg-neutral-background-3 px-2 py-1 font-mono text-base-300"> 398 + {newPasswordResult.secret} 399 + </code> 400 + <p class="text-base-200 text-neutral-foreground-3"> 401 + Copy this password now. You won't be able to see it again. 402 + </p> 403 + </div> 404 + </MessageBarBody> 405 + </MessageBar> 406 + )} 407 + 408 + {newPasswordError && ( 409 + <MessageBar intent="error" layout="singleline"> 410 + <MessageBarBody>{newPasswordError.message}</MessageBarBody> 411 + </MessageBar> 412 + )} 413 + 414 + <div class="flex flex-col divide-y divide-neutral-stroke-2 rounded-md bg-neutral-background-1 shadow-4"> 415 + {passwords.length === 0 && ( 416 + <div class="flex flex-col gap-1 p-8 text-center"> 417 + <p class="text-base-300 font-medium">No app passwords created</p> 418 + <p class="text-base-300 text-neutral-foreground-3"> 419 + App passwords lets you sign into legacy AT Protocol apps. 420 + </p> 421 + </div> 422 + )} 423 + 424 + {passwords.map((password) => { 425 + let privilege = `Unknown`; 426 + switch (password.privilege) { 427 + case AppPasswordPrivilege.Full: { 428 + privilege = `Full access`; 429 + break; 430 + } 431 + case AppPasswordPrivilege.Privileged: { 432 + privilege = `Privileged access`; 433 + break; 434 + } 435 + case AppPasswordPrivilege.Limited: { 436 + privilege = `Limited access`; 437 + break; 438 + } 439 + } 440 + 441 + return ( 442 + <div class="flex items-center gap-4 px-4 py-3"> 443 + <Key2Outlined size={24} class="shrink-0 text-neutral-foreground-3" /> 444 + 445 + <div class="min-w-0 grow"> 446 + <p class="text-base-300 font-medium">{password.name}</p> 447 + <p class="text-base-300 text-neutral-foreground-3"> 448 + {privilege} · created {password.created_at.toLocaleDateString()} 449 + </p> 450 + </div> 451 + 452 + <form {...deleteAppPasswordForm} class="contents"> 453 + <input type="hidden" name="name" value={password.name} /> 454 + <Button type="submit" variant="subtle"> 455 + <TrashCanOutlined size={16} /> 456 + </Button> 457 + </form> 458 + </div> 459 + ); 460 + })} 461 + </div> 462 + </div> 463 + 464 + <Dialog id="create-app-password-dialog"> 465 + <DialogSurface> 466 + <DialogBody> 467 + <DialogTitle>Create app password</DialogTitle> 468 + 469 + <form {...createAppPasswordForm} class="contents"> 470 + <DialogContent class="flex flex-col gap-4"> 471 + <p class="text-base-300 text-neutral-foreground-3"> 472 + App passwords let you sign into legacy AT Protocol apps without giving them access to 473 + your main password. 474 + </p> 475 + 476 + <Field label="Name" required> 477 + <Input {...createAppPasswordForm.fields.name.as('text')} placeholder="App" required /> 478 + </Field> 479 + 480 + <Field label="Privilege" required> 481 + <Select 482 + {...createAppPasswordForm.fields.privilege.as('select')} 483 + options={[ 484 + { value: 'limited', label: 'Limited access' }, 485 + { value: 'privileged', label: 'Privileged access' }, 486 + { value: 'full', label: 'Full access' }, 487 + ]} 488 + /> 489 + </Field> 490 + </DialogContent> 491 + 492 + <DialogActions> 493 + <DialogClose> 494 + <Button>Cancel</Button> 495 + </DialogClose> 496 + 497 + <Button type="submit" variant="primary"> 498 + Create 499 + </Button> 500 + </DialogActions> 501 + </form> 502 + </DialogBody> 503 + </DialogSurface> 504 + </Dialog> 505 + </AccountLayout>, 506 + ); 507 + }, 508 + 509 + security() { 510 + const ctx = getAppContext(); 511 + const session = getSession(); 512 + const account = ctx.accountManager.getAccount(session.did); 513 + 514 + return render( 515 + <AccountLayout> 516 + <title>Security - Danaus</title> 517 + 518 + <div class="flex flex-col gap-4"> 519 + <div class="flex h-8 shrink-0 items-center"> 520 + <h3 class="text-base-400 font-medium">Security</h3> 521 + </div> 522 + 523 + <div class="flex flex-col gap-8"> 524 + <div class="flex flex-col gap-2"> 525 + <h4 class="text-base-300 font-medium text-neutral-foreground-2">Account information</h4> 526 + 527 + <div class="flex flex-col divide-y divide-neutral-stroke-2 rounded-md bg-neutral-background-1 shadow-4"> 528 + <div class="flex items-center gap-4 px-4 py-3"> 529 + <div class="min-w-0 grow"> 530 + <p class="text-base-300 font-medium wrap-break-word">{account?.email}</p> 531 + <p class="text-base-300 text-neutral-foreground-3"> 532 + {account?.email_confirmed_at ? 'Verified' : 'Not verified'} 533 + </p> 534 + </div> 535 + 536 + {!account?.email_confirmed_at && <Button>Verify</Button>} 537 + 538 + <Menu> 539 + <MenuTrigger> 540 + <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 541 + <DotGrid1x3HorizontalOutlined size={16} /> 542 + </button> 543 + </MenuTrigger> 544 + 545 + <MenuPopover> 546 + <MenuList> 547 + <MenuItem>Change email</MenuItem> 548 + </MenuList> 549 + </MenuPopover> 550 + </Menu> 551 + </div> 552 + </div> 553 + </div> 554 + 555 + <div class="flex flex-col gap-2"> 556 + <h4 class="text-base-300 font-medium text-neutral-foreground-2">Ways to prove who you are</h4> 557 + 558 + <div class="flex flex-col divide-y divide-neutral-stroke-2 rounded-md bg-neutral-background-1 shadow-4"> 559 + <div class="flex items-center gap-4 px-4 py-3"> 560 + <PasswordOutlined size={24} class="shrink-0" /> 561 + 562 + <div class="min-w-0 grow"> 563 + <p class="text-base-300 font-medium wrap-break-word">Password</p> 564 + <p class="text-base-300 text-neutral-foreground-3">Last changed yesterday</p> 565 + </div> 566 + 567 + <Menu> 568 + <MenuTrigger> 569 + <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 570 + <DotGrid1x3HorizontalOutlined size={16} /> 571 + </button> 572 + </MenuTrigger> 573 + 574 + <MenuPopover> 575 + <MenuList> 576 + <MenuItem>Change password</MenuItem> 577 + </MenuList> 578 + </MenuPopover> 579 + </Menu> 580 + </div> 581 + 582 + <div class="flex items-center gap-4 px-4 py-3"> 583 + <PhoneOutlined size={24} class="shrink-0" /> 584 + 585 + <div class="min-w-0 grow"> 586 + <p class="text-base-300 font-medium wrap-break-word">Bitwarden</p> 587 + <p class="text-base-300 text-neutral-foreground-3">Authenticator · Added yesterday</p> 588 + </div> 589 + 590 + <Menu> 591 + <MenuTrigger> 592 + <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 593 + <DotGrid1x3HorizontalOutlined size={16} /> 594 + </button> 595 + </MenuTrigger> 596 + 597 + <MenuPopover> 598 + <MenuList> 599 + <MenuItem>Rename</MenuItem> 600 + <MenuDivider /> 601 + <MenuItem>Remove</MenuItem> 602 + </MenuList> 603 + </MenuPopover> 604 + </Menu> 605 + </div> 606 + 607 + <div class="flex items-center gap-4 px-4 py-3"> 608 + <UsbOutlined size={24} class="shrink-0" /> 609 + 610 + <div class="min-w-0 grow"> 611 + <p class="text-base-300 font-medium wrap-break-word">YubiKey 5</p> 612 + <p class="text-base-300 text-neutral-foreground-3">Security key · Added 2 weeks ago</p> 613 + </div> 614 + 615 + <Menu> 616 + <MenuTrigger> 617 + <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 618 + <DotGrid1x3HorizontalOutlined size={16} /> 619 + </button> 620 + </MenuTrigger> 621 + 622 + <MenuPopover> 623 + <MenuList> 624 + <MenuItem>Rename</MenuItem> 625 + <MenuDivider /> 626 + <MenuItem>Remove</MenuItem> 627 + </MenuList> 628 + </MenuPopover> 629 + </Menu> 630 + </div> 631 + 632 + <div class="flex items-center gap-4 px-4 py-3"> 633 + <PasskeysOutlined size={24} class="shrink-0" /> 634 + 635 + <div class="min-w-0 grow"> 636 + <p class="text-base-300 font-medium wrap-break-word">iCloud Keychain</p> 637 + <p class="text-base-300 text-neutral-foreground-3">Passkey · Added last month</p> 638 + </div> 639 + 640 + <Menu> 641 + <MenuTrigger> 642 + <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 643 + <DotGrid1x3HorizontalOutlined size={16} /> 644 + </button> 645 + </MenuTrigger> 646 + 647 + <MenuPopover> 648 + <MenuList> 649 + <MenuItem>Rename</MenuItem> 650 + <MenuDivider /> 651 + <MenuItem>Remove</MenuItem> 652 + </MenuList> 653 + </MenuPopover> 654 + </Menu> 655 + </div> 656 + 657 + <button class="flex items-center gap-4 bg-subtle-background px-4 py-3 text-left outline-2 -outline-offset-2 outline-transparent transition select-none first:rounded-t-md last:rounded-b-md hover:bg-subtle-background-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active"> 658 + <div class="grid h-6 w-6 shrink-0 place-items-center"> 659 + <PlusLargeOutlined size={16} /> 660 + </div> 661 + 662 + <div class="min-w-0 grow"> 663 + <p class="text-base-300">Add another way to sign in</p> 664 + </div> 665 + </button> 666 + </div> 667 + </div> 668 + </div> 669 + </div> 670 + </AccountLayout>, 671 + ); 672 + }, 673 + }, 674 + } satisfies Controller<typeof routes.account>;
+209
packages/danaus/src/web/controllers/admin.tsx
··· 1 + import type { Controller } from '@oomfware/fetch-router'; 2 + import { forms } from '@oomfware/forms'; 3 + import { render } from '@oomfware/jsx'; 4 + 5 + import StatCard from '../admin/components/stat-card.tsx'; 6 + import { createAccountForm } from '../admin/forms.ts'; 7 + import MagnifyingGlassOutlined from '../icons/central/magnifying-glass-outlined.tsx'; 8 + import PlusLargeOutlined from '../icons/central/plus-large-outlined.tsx'; 9 + import { AdminLayout } from '../layouts/admin.tsx'; 10 + import { getAppContext } from '../middlewares/app-context.ts'; 11 + import { requireAdmin } from '../middlewares/basic-auth.ts'; 12 + import Button from '../primitives/button.tsx'; 13 + import Field from '../primitives/field.tsx'; 14 + import Input from '../primitives/input.tsx'; 15 + import Select from '../primitives/select.tsx'; 16 + import { routes } from '../routes.ts'; 17 + 18 + export default { 19 + middleware: [requireAdmin(), forms({ createAccountForm })], 20 + actions: { 21 + dashboard() { 22 + const ctx = getAppContext(); 23 + const accountStats = ctx.accountManager.getAccountStats(); 24 + const inviteCodeStats = ctx.accountManager.getInviteCodeStats(); 25 + const sequencerStats = ctx.sequencer.getStats(); 26 + 27 + return render( 28 + <AdminLayout> 29 + <title>Home - Danaus admin</title> 30 + 31 + <div class="flex flex-col gap-4"> 32 + <h3 class="text-base-400 font-medium">Home</h3> 33 + 34 + <div class="flex flex-col gap-6"> 35 + <div class="flex flex-col gap-2"> 36 + <h4 class="text-base-300 font-medium text-neutral-foreground-2">Accounts</h4> 37 + <div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5"> 38 + <StatCard label="Total" value={accountStats.total} /> 39 + <StatCard label="Active" value={accountStats.active} /> 40 + <StatCard label="Deactivated" value={accountStats.deactivated} /> 41 + <StatCard label="Taken down" value={accountStats.takendown} /> 42 + <StatCard label="Delete scheduled" value={accountStats.deleteScheduled} /> 43 + </div> 44 + </div> 45 + 46 + <div class="flex flex-col gap-2"> 47 + <h4 class="text-base-300 font-medium text-neutral-foreground-2">Invite codes</h4> 48 + <div class="grid grid-cols-2 gap-3 sm:grid-cols-4"> 49 + <StatCard label="Total" value={inviteCodeStats.total} /> 50 + <StatCard label="Available" value={inviteCodeStats.available} /> 51 + <StatCard label="Used" value={inviteCodeStats.used} /> 52 + <StatCard label="Disabled" value={inviteCodeStats.disabled} /> 53 + </div> 54 + </div> 55 + 56 + <div class="flex flex-col gap-2"> 57 + <h4 class="text-base-300 font-medium text-neutral-foreground-2">Sequencer</h4> 58 + <div class="grid grid-cols-2 gap-3 sm:grid-cols-3"> 59 + <StatCard label="Last seq" value={sequencerStats.lastSeq} /> 60 + <StatCard label="Total events" value={sequencerStats.totalEvents} /> 61 + <StatCard label="Invalidated events" value={sequencerStats.invalidatedEvents} /> 62 + </div> 63 + </div> 64 + </div> 65 + </div> 66 + </AdminLayout>, 67 + ); 68 + }, 69 + 70 + accounts: { 71 + index({ url }) { 72 + const ctx = getAppContext(); 73 + const query = url.searchParams.get('q') ?? ''; 74 + const cursor = url.searchParams.get('cursor') ?? undefined; 75 + 76 + const { accounts, cursor: nextCursor } = ctx.accountManager.listAccounts({ 77 + query: query || undefined, 78 + cursor, 79 + limit: 50, 80 + }); 81 + 82 + const buildHref = (nextCursor: string) => { 83 + return routes.admin.accounts.index.href(undefined, { 84 + q: query || undefined, 85 + cursor: nextCursor, 86 + }); 87 + }; 88 + 89 + return render( 90 + <AdminLayout> 91 + <title>Accounts - Danaus admin</title> 92 + 93 + <div class="flex flex-col gap-4"> 94 + <h3 class="text-base-400 font-medium">Accounts</h3> 95 + 96 + <div class="flex gap-2"> 97 + <form method="get" action={routes.admin.accounts.index.href()} class="contents"> 98 + <Input 99 + type="search" 100 + name="q" 101 + value={query} 102 + placeholder="Search by handle or email..." 103 + contentBefore={<MagnifyingGlassOutlined size={16} />} 104 + class="grow" 105 + /> 106 + </form> 107 + 108 + <Button label="New account" href={routes.admin.accounts.create.href()} variant="primary"> 109 + <PlusLargeOutlined size={16} /> 110 + New 111 + </Button> 112 + </div> 113 + 114 + <div class="flex flex-col"> 115 + {accounts.length === 0 ? ( 116 + <p class="py-8 text-center text-base-300 text-neutral-foreground-3"> 117 + {query ? 'No accounts found matching your search.' : 'No accounts yet.'} 118 + </p> 119 + ) : ( 120 + <ul class="divide-y divide-neutral-stroke-2"> 121 + {accounts.map((account) => ( 122 + <li class="flex items-center justify-between gap-4 py-3"> 123 + <div class="flex min-w-0 flex-col"> 124 + <span class="truncate text-base-300 font-medium">@{account.handle}</span> 125 + <span class="truncate text-base-200 text-neutral-foreground-3"> 126 + {account.email} 127 + </span> 128 + </div> 129 + <div class="flex shrink-0 gap-2 text-base-200 text-neutral-foreground-3"> 130 + {account.deactivated_at && ( 131 + <span class="rounded-md bg-neutral-background-3 px-1.5 py-0.5 text-neutral-foreground-2"> 132 + deactivated 133 + </span> 134 + )} 135 + {account.takedown_ref && ( 136 + <span class="rounded-md bg-status-danger-background-1 px-1.5 py-0.5 text-status-danger-foreground-1"> 137 + taken down 138 + </span> 139 + )} 140 + </div> 141 + </li> 142 + ))} 143 + </ul> 144 + )} 145 + </div> 146 + 147 + {nextCursor && ( 148 + <div class="flex justify-end"> 149 + <Button href={buildHref(nextCursor)} variant="outlined"> 150 + Next page 151 + </Button> 152 + </div> 153 + )} 154 + </div> 155 + </AdminLayout>, 156 + ); 157 + }, 158 + 159 + create() { 160 + const ctx = getAppContext(); 161 + const domains = ctx.config.identity.serviceHandleDomains; 162 + const domainOptions = domains.map((d) => ({ value: d, label: d })); 163 + 164 + const { fields } = createAccountForm; 165 + 166 + return render( 167 + <AdminLayout> 168 + <title>New account - Danaus admin</title> 169 + 170 + <div class="flex flex-col gap-4"> 171 + <h3 class="text-base-400 font-medium">New account</h3> 172 + 173 + <form {...createAccountForm} class="flex max-w-96 flex-col gap-6"> 174 + <Field label="Handle" required validationMessageText={fields.handle.issues()?.[0]!.message}> 175 + <div class="flex gap-2"> 176 + <Input {...fields.handle.as('text')} placeholder="alice" required class="grow" /> 177 + 178 + <Select {...fields.domain.as('select')} options={domainOptions} /> 179 + </div> 180 + </Field> 181 + 182 + <Field label="Email" required validationMessageText={fields.email.issues()?.[0]!.message}> 183 + <Input {...fields.email.as('email')} placeholder="alice@example.com" required /> 184 + </Field> 185 + 186 + <Field 187 + label="Password" 188 + required 189 + validationMessageText={fields.password.issues()?.[0]!.message} 190 + > 191 + <Input {...fields.password.as('password')} required /> 192 + </Field> 193 + 194 + <div class="flex gap-3 pt-2"> 195 + <Button type="submit" variant="primary"> 196 + Create account 197 + </Button> 198 + <Button href={routes.admin.accounts.index.href()} variant="outlined"> 199 + Cancel 200 + </Button> 201 + </div> 202 + </form> 203 + </div> 204 + </AdminLayout>, 205 + ); 206 + }, 207 + }, 208 + }, 209 + } satisfies Controller<typeof routes.admin>;
+10
packages/danaus/src/web/controllers/home.tsx
··· 1 + import type { BuildAction } from '@oomfware/fetch-router'; 2 + 3 + import type { routes } from '../routes'; 4 + 5 + export default { 6 + middleware: [], 7 + action() { 8 + return new Response('This is an AT Protocol personal data server.'); 9 + }, 10 + } satisfies BuildAction<'ANY', typeof routes.home>;
+51
packages/danaus/src/web/controllers/login.tsx
··· 1 + import type { BuildAction } from '@oomfware/fetch-router'; 2 + import { forms } from '@oomfware/forms'; 3 + import { render } from '@oomfware/jsx'; 4 + 5 + import { signInForm } from '../account/forms.ts'; 6 + import { BaseLayout } from '../layouts/base.tsx'; 7 + import Button from '../primitives/button.tsx'; 8 + import Field from '../primitives/field.tsx'; 9 + import Input from '../primitives/input.tsx'; 10 + import type { routes } from '../routes.ts'; 11 + 12 + export default { 13 + middleware: [forms({ signInForm })], 14 + action() { 15 + const { fields } = signInForm; 16 + 17 + return render( 18 + <BaseLayout> 19 + <title>sign in - danaus</title> 20 + 21 + <div class="flex flex-1 items-center justify-center p-4"> 22 + <div class="w-full max-w-96 rounded-xl bg-neutral-background-1 p-6 shadow-16"> 23 + <form {...signInForm} class="flex flex-col gap-6"> 24 + <h1 class="text-base-500 font-semibold">Sign in to your account</h1> 25 + 26 + <Field 27 + label="Handle or email" 28 + required 29 + validationMessageText={fields.identifier.issues()?.[0]!.message} 30 + > 31 + <Input {...fields.identifier.as('text')} placeholder="alice.bsky.social" required autofocus /> 32 + </Field> 33 + 34 + <Field 35 + label="Password" 36 + required 37 + validationMessageText={fields._password.issues()?.[0]!.message} 38 + > 39 + <Input {...fields._password.as('password')} required /> 40 + </Field> 41 + 42 + <Button type="submit" variant="primary"> 43 + Sign in 44 + </Button> 45 + </form> 46 + </div> 47 + </div> 48 + </BaseLayout>, 49 + ); 50 + }, 51 + } satisfies BuildAction<'ANY', typeof routes.home>;
+36
packages/danaus/src/web/controllers/oauth.tsx
··· 1 + import type { Controller } from '@oomfware/fetch-router'; 2 + import { render } from '@oomfware/jsx'; 3 + 4 + import { BaseLayout } from '../layouts/base.tsx'; 5 + import { requireSession } from '../middlewares/session.ts'; 6 + import Button from '../primitives/button.tsx'; 7 + import { routes } from '../routes.ts'; 8 + 9 + export default { 10 + authorize: { 11 + middleware: [requireSession()], 12 + action() { 13 + return render( 14 + <BaseLayout> 15 + <title>authorize - danaus</title> 16 + 17 + <div class="flex flex-1 items-center justify-center p-4"> 18 + <div class="w-full max-w-96 rounded-xl bg-neutral-background-1 p-6 shadow-16"> 19 + <div class="flex flex-col gap-4"> 20 + <h1 class="text-base-500 font-semibold">authorize application</h1> 21 + 22 + <p class="text-base-300 text-neutral-foreground-3"> 23 + OAuth authorization is not yet implemented. 24 + </p> 25 + 26 + <Button href={routes.account.overview.href()} variant="outlined"> 27 + Back to account 28 + </Button> 29 + </div> 30 + </div> 31 + </div> 32 + </BaseLayout>, 33 + ); 34 + }, 35 + }, 36 + } satisfies Controller<typeof routes.oauth>;
-592
packages/danaus/src/web/forms/index.ts
··· 1 - import { AsyncLocalStorage } from 'node:async_hooks'; 2 - 3 - import type { StandardSchemaV1 } from '@standard-schema/spec'; 4 - import type { Context, MiddlewareHandler, Next } from 'hono'; 5 - import { HTTPException } from 'hono/http-exception'; 6 - import type { ContentfulStatusCode } from 'hono/utils/http-status'; 7 - 8 - // #region types 9 - export interface FormIssue { 10 - path: (string | number)[]; 11 - message: string; 12 - } 13 - 14 - interface FormState { 15 - input: Record<string, unknown>; 16 - /** flattened issues map keyed by path string, '$' contains all issues */ 17 - issues: Record<string, FormIssue[]>; 18 - result?: unknown; 19 - } 20 - 21 - interface FormConfig { 22 - id: string; 23 - action: string; 24 - } 25 - 26 - interface FormStore { 27 - definitions: WeakMap<FormDefinition<any, any>, FormConfig>; 28 - state: Map<string, FormState>; 29 - } 30 - 31 - interface FormHandlerStore { 32 - context: Context; 33 - } 34 - 35 - export interface FormDefinition<TInput, TOutput> { 36 - /** the form action URL */ 37 - readonly action: string; 38 - /** the form method */ 39 - readonly method: 'post'; 40 - /** proxy for accessing field values and issues */ 41 - readonly fields: FieldsProxy<TInput>; 42 - /** the result of the form handler, if successful */ 43 - readonly result: TOutput | undefined; 44 - /** internal metadata */ 45 - readonly __: { 46 - schema: StandardSchemaV1<TInput>; 47 - handler: (data: TInput, issue: IssueBuilder<TInput>) => Promise<TOutput>; 48 - }; 49 - } 50 - 51 - export type IssueBuilder<T> = { 52 - [K in keyof T]: T[K] extends Record<string, unknown> 53 - ? IssueBuilder<T[K]> & ((message: string) => FormIssue) 54 - : (message: string) => FormIssue; 55 - } & ((message: string) => FormIssue); 56 - 57 - export type FieldsProxy<T> = { 58 - [K in keyof T]: T[K] extends Record<string, unknown> 59 - ? FieldsProxy<T[K]> & FieldAccessor<T[K]> 60 - : FieldAccessor<T[K]>; 61 - } & FieldAccessor<T>; 62 - 63 - export type FormFieldValue = string | string[] | number | boolean | File | File[]; 64 - 65 - export type FormFieldType<T> = { 66 - [K in keyof InputTypeMap]: T extends InputTypeMap[K] ? K : never; 67 - }[keyof InputTypeMap]; 68 - 69 - export interface FieldAccessor<Value> { 70 - /** returns the current input value for this field */ 71 - value(): Value; 72 - /** returns validation issues for this exact field path */ 73 - issues(): { path: (string | number)[]; message: string }[]; 74 - /** returns validation issues for this field and all nested fields */ 75 - allIssues(): { path: (string | number)[]; message: string }[]; 76 - /** returns props for an input element */ 77 - as<T extends FormFieldType<Value>>(...args: AsArgs<T, Value>): Record<string, unknown>; 78 - } 79 - 80 - type InputTypeMap = { 81 - text: string; 82 - email: string; 83 - password: string; 84 - url: string; 85 - tel: string; 86 - search: string; 87 - number: number; 88 - range: number; 89 - date: string; 90 - 'datetime-local': string; 91 - time: string; 92 - month: string; 93 - week: string; 94 - color: string; 95 - checkbox: boolean | string[]; 96 - radio: string; 97 - file: File; 98 - hidden: string; 99 - submit: string; 100 - button: string; 101 - reset: string; 102 - image: string; 103 - select: string; 104 - 'select multiple': string[]; 105 - 'file multiple': File[]; 106 - }; 107 - 108 - type InputType = keyof InputTypeMap; 109 - 110 - type AsArgs<Type extends InputType, Value> = Type extends 'checkbox' 111 - ? Value extends string[] 112 - ? [type: Type, value: Value[number] | (string & {})] 113 - : [type: Type] 114 - : Type extends 'radio' | 'submit' | 'hidden' 115 - ? [type: Type, value: Value | (string & {})] 116 - : [type: Type]; 117 - // #endregion 118 - 119 - // #region async local storage 120 - const formStore = new AsyncLocalStorage<FormStore>(); 121 - const formHandlerStore = new AsyncLocalStorage<FormHandlerStore>(); 122 - 123 - const getFormConfig = (form: FormDefinition<any, any>): FormConfig | undefined => { 124 - return formStore.getStore()?.definitions.get(form); 125 - }; 126 - 127 - const getFormState = (id: string): FormState | undefined => { 128 - return formStore.getStore()?.state.get(id); 129 - }; 130 - 131 - /** 132 - * returns the current request context from within a form handler 133 - * @returns the hono context object 134 - * @throws if called outside of a form handler context 135 - */ 136 - export const getRequestContext = (): Context => { 137 - const store = formHandlerStore.getStore(); 138 - if (!store) { 139 - throw new Error('getRequestContext called outside of form handler'); 140 - } 141 - 142 - return store.context; 143 - }; 144 - // #endregion 145 - 146 - // #region form factory 147 - /** 148 - * creates a form definition with schema validation and handler 149 - * @param schema standard schema for input validation 150 - * @param handler async function to process validated form data 151 - * @returns form definition object 152 - */ 153 - export const form = <TInput extends Record<string, unknown>, TOutput>( 154 - schema: StandardSchemaV1<TInput>, 155 - handler: (data: TInput, issue: IssueBuilder<TInput>) => Promise<TOutput>, 156 - ): FormDefinition<TInput, TOutput> => { 157 - const definition = {} as FormDefinition<TInput, TOutput>; 158 - 159 - const getConfig = () => { 160 - const config = getFormConfig(definition); 161 - if (!config) { 162 - throw new Error('Form accessed outside of registered context'); 163 - } 164 - return config; 165 - }; 166 - 167 - // enumerable - included in spread 168 - Object.defineProperties(definition, { 169 - action: { 170 - enumerable: true, 171 - get: () => getConfig().action, 172 - }, 173 - method: { 174 - enumerable: true, 175 - value: 'post', 176 - }, 177 - }); 178 - 179 - // non-enumerable - excluded from spread 180 - Object.defineProperties(definition, { 181 - fields: { 182 - enumerable: false, 183 - get: () => { 184 - const state = getFormState(getConfig().id); 185 - return createFieldsProxy<TInput>( 186 - () => state?.input ?? {}, 187 - () => state?.issues ?? {}, 188 - ); 189 - }, 190 - }, 191 - result: { 192 - enumerable: false, 193 - get: () => { 194 - const config = getFormConfig(definition); 195 - if (!config) { 196 - return undefined; 197 - } 198 - return getFormState(config.id)?.result as TOutput | undefined; 199 - }, 200 - }, 201 - __: { 202 - enumerable: false, 203 - value: { schema, handler }, 204 - }, 205 - }); 206 - 207 - return definition; 208 - }; 209 - // #endregion 210 - 211 - // #region middleware 212 - const EMPTY_STATE = new Map<string, FormState>(); 213 - 214 - /** 215 - * registers form handlers as middleware 216 - * @param forms object mapping form IDs to form definitions 217 - * @returns hono middleware handler 218 - */ 219 - export const registerForms = (forms: Record<string, FormDefinition<any, any>>): MiddlewareHandler => { 220 - const definitions = new WeakMap<FormDefinition<any, any>, FormConfig>(); 221 - for (const [id, form] of Object.entries(forms)) { 222 - definitions.set(form, { id, action: `?__action=${id}` }); 223 - } 224 - 225 - return async (c: Context, next: Next) => { 226 - let state: Map<string, FormState> | undefined; 227 - 228 - jmp: { 229 - if (c.req.method !== 'POST') { 230 - break jmp; 231 - } 232 - 233 - const actionId = c.req.query('__action'); 234 - if (actionId === undefined) { 235 - break jmp; 236 - } 237 - 238 - const form = forms[actionId]; 239 - if (form === undefined) { 240 - break jmp; 241 - } 242 - 243 - const fetchSite = c.req.header('sec-fetch-site'); 244 - if (fetchSite !== 'same-origin') { 245 - throw new HTTPException(403, { message: 'cross-origin form submission rejected' }); 246 - } 247 - 248 - const formData = await c.req.formData(); 249 - const input = convertFormData(formData); 250 - 251 - // validate with schema 252 - const validated = await form.__.schema['~standard'].validate(input); 253 - 254 - state ??= new Map(); 255 - 256 - if (validated.issues) { 257 - state.set(actionId, { 258 - input, 259 - issues: flattenIssues(normalizeIssues(validated.issues)), 260 - }); 261 - 262 - break jmp; 263 - } 264 - 265 - const issueBuilder = createIssueBuilder<any>(); 266 - 267 - try { 268 - const result = await formHandlerStore.run({ context: c }, async () => { 269 - return await form.__.handler(validated.value, issueBuilder); 270 - }); 271 - 272 - state.set(actionId, { input: {}, issues: {}, result }); 273 - } catch (err) { 274 - if (err instanceof ValidationError) { 275 - state.set(actionId, { input, issues: flattenIssues(err.issues) }); 276 - } else { 277 - throw err; 278 - } 279 - } 280 - } 281 - 282 - await formStore.run({ definitions: definitions, state: state ?? EMPTY_STATE }, next); 283 - }; 284 - }; 285 - // #endregion 286 - 287 - // #region validation error 288 - /** 289 - * error thrown to indicate form validation failure 290 - */ 291 - export class ValidationError extends Error { 292 - readonly issues: FormIssue[]; 293 - 294 - constructor(issues: FormIssue[]) { 295 - super('Validation failed'); 296 - this.name = 'ValidationError'; 297 - this.issues = issues; 298 - } 299 - } 300 - 301 - /** 302 - * throws a validation error with the given issues 303 - * @param issues one or more form issues 304 - */ 305 - export const invalid: { 306 - (...issues: (FormIssue | string)[]): never; 307 - } = (...issues: (FormIssue | string)[]): never => { 308 - throw new ValidationError( 309 - issues.map((issue) => (typeof issue === 'string' ? { path: [], message: issue } : issue)), 310 - ); 311 - }; 312 - // #endregion 313 - 314 - // #region redirect 315 - type RedirectStatus = 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308; 316 - 317 - /** 318 - * throws an HTTP redirect exception 319 - * @param status redirect status code 320 - * @param location target URL 321 - */ 322 - export const redirect: { 323 - (status: RedirectStatus, location: string): never; 324 - } = (status: RedirectStatus, location: string): never => { 325 - throw new HTTPException(status as ContentfulStatusCode, { 326 - res: new Response(null, { status, headers: { location } }), 327 - }); 328 - }; 329 - // #endregion 330 - 331 - // #region issue builder 332 - const createIssueBuilder = <T>(): IssueBuilder<T> => { 333 - const createProxy = (path: (string | number)[]): any => { 334 - const issueFunc = (message: string): FormIssue => ({ path, message }); 335 - 336 - return new Proxy(issueFunc, { 337 - get(_, prop) { 338 - if (typeof prop === 'symbol') { 339 - return undefined; 340 - } 341 - 342 - const key = /^\d+$/.test(prop) ? parseInt(prop, 10) : prop; 343 - return createProxy([...path, key]); 344 - }, 345 - }); 346 - }; 347 - 348 - return createProxy([]); 349 - }; 350 - // #endregion 351 - 352 - // #region fields proxy 353 - const createFieldsProxy = <T>( 354 - getInput: () => Record<string, unknown>, 355 - getIssues: () => Record<string, FormIssue[]>, 356 - path: (string | number)[] = [], 357 - ): FieldsProxy<T> => { 358 - const getValue = (): unknown => { 359 - let current: unknown = getInput(); 360 - for (const key of path) { 361 - if (current == null || typeof current !== 'object') { 362 - return undefined; 363 - } 364 - current = (current as Record<string | number, unknown>)[key]; 365 - } 366 - return current; 367 - }; 368 - 369 - const buildName = (): string => { 370 - let name = ''; 371 - for (const segment of path) { 372 - if (typeof segment === 'number') { 373 - name += `[${segment}]`; 374 - } else { 375 - name += name === '' ? segment : `.${segment}`; 376 - } 377 - } 378 - return name; 379 - }; 380 - 381 - const pathKey = buildName() || '$'; 382 - 383 - const accessor = { 384 - value: getValue, 385 - issues: () => { 386 - const issues = getIssues()[pathKey] ?? []; 387 - const pathStr = path.join('.'); 388 - return issues 389 - .filter((issue) => issue.path.join('.') === pathStr) 390 - .map((issue) => ({ path: issue.path, message: issue.message })); 391 - }, 392 - allIssues: () => { 393 - const issues = getIssues()[pathKey] ?? []; 394 - return issues.map((issue) => ({ path: issue.path, message: issue.message })); 395 - }, 396 - as: (type: InputType, inputValue?: string) => { 397 - const baseName = buildName(); 398 - const issues = getIssues()[pathKey] ?? []; 399 - const pathStr = path.join('.'); 400 - const hasError = issues.some((i) => i.path.join('.') === pathStr); 401 - 402 - const isArray = 403 - type === 'file multiple' || 404 - type === 'select multiple' || 405 - (type === 'checkbox' && typeof inputValue === 'string'); 406 - 407 - const prefix = 408 - type === 'number' || type === 'range' ? 'n:' : type === 'checkbox' && !isArray ? 'b:' : ''; 409 - 410 - const props: Record<string, unknown> = { 411 - name: prefix + baseName + (isArray ? '[]' : ''), 412 - 'aria-invalid': hasError ? 'true' : undefined, 413 - }; 414 - 415 - // add type attribute for non-text, non-select elements 416 - if (type !== 'text' && type !== 'select' && type !== 'select multiple') { 417 - props.type = type === 'file multiple' ? 'file' : type; 418 - } 419 - 420 - // submit and hidden require inputValue 421 - if (type === 'submit' || type === 'hidden') { 422 - props.value = inputValue; 423 - return props; 424 - } 425 - 426 - // select inputs 427 - if (type === 'select' || type === 'select multiple') { 428 - props.multiple = isArray; 429 - props.value = getValue(); 430 - return props; 431 - } 432 - 433 - // checkbox and radio inputs 434 - if (type === 'checkbox' || type === 'radio') { 435 - props.value = inputValue ?? 'on'; 436 - const value = getValue(); 437 - 438 - if (type === 'radio') { 439 - props.checked = value === inputValue; 440 - } else if (isArray) { 441 - props.checked = Array.isArray(value) && value.includes(inputValue); 442 - } else { 443 - props.checked = !!value; 444 - } 445 - 446 - return props; 447 - } 448 - 449 - // file inputs 450 - if (type === 'file' || type === 'file multiple') { 451 - props.multiple = isArray; 452 - return props; 453 - } 454 - 455 - // all other text-like inputs 456 - const value = getValue(); 457 - props.value = value != null ? String(value) : ''; 458 - return props; 459 - }, 460 - }; 461 - 462 - return new Proxy(accessor as FieldsProxy<T>, { 463 - get(_, prop) { 464 - if (typeof prop === 'symbol') { 465 - return undefined; 466 - } 467 - 468 - // return accessor methods 469 - if (prop === 'value' || prop === 'issues' || prop === 'allIssues' || prop === 'as') { 470 - return accessor[prop]; 471 - } 472 - 473 - // nested field access 474 - const key = /^\d+$/.test(prop) ? parseInt(prop, 10) : prop; 475 - return createFieldsProxy(getInput, getIssues, [...path, key]); 476 - }, 477 - }); 478 - }; 479 - // #endregion 480 - 481 - // #region form data conversion 482 - const convertFormData = (data: FormData): Record<string, unknown> => { 483 - const result: Record<string, unknown> = {}; 484 - 485 - for (let key of data.keys()) { 486 - const isArray = key.endsWith('[]'); 487 - let values: unknown[] = data.getAll(key); 488 - 489 - if (isArray) { 490 - key = key.slice(0, -2); 491 - } 492 - 493 - // reject duplicate non-array keys 494 - if (values.length > 1 && !isArray) { 495 - throw new Error(`Form cannot contain duplicated keys — "${key}" has ${values.length} values`); 496 - } 497 - 498 - // filter empty file inputs (browsers submit a File with empty name for empty file inputs) 499 - values = values.filter( 500 - (entry) => 501 - typeof entry === 'string' || (entry instanceof File && (entry.name !== '' || entry.size > 0)), 502 - ); 503 - 504 - // handle type coercion prefixes 505 - if (key.startsWith('n:')) { 506 - key = key.slice(2); 507 - values = values.map((v) => (v === '' ? undefined : parseFloat(v as string))); 508 - } else if (key.startsWith('b:')) { 509 - key = key.slice(2); 510 - values = values.map((v) => v === 'on'); 511 - } 512 - 513 - setNestedValue(result, key, isArray ? values : values[0]); 514 - } 515 - 516 - return result; 517 - }; 518 - 519 - const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']); 520 - 521 - const setNestedValue = (obj: Record<string, unknown>, path: string, value: unknown): void => { 522 - const keys = path.split(/\.|\[|\]/).filter((k): k is string => k !== ''); 523 - 524 - if (keys.length === 0) { 525 - return; 526 - } 527 - 528 - let current = obj; 529 - 530 - for (let i = 0; i < keys.length - 1; i++) { 531 - const key = keys[i]!; 532 - 533 - if (DANGEROUS_KEYS.has(key)) { 534 - throw new Error(`Invalid key "${key}"`); 535 - } 536 - 537 - const nextKey = keys[i + 1]!; 538 - const isNextArray = /^\d+$/.test(nextKey); 539 - const exists = key in current; 540 - const inner = current[key]; 541 - 542 - if (exists && isNextArray !== Array.isArray(inner)) { 543 - throw new Error(`Invalid array key "${nextKey}"`); 544 - } 545 - 546 - if (!exists) { 547 - current[key] = isNextArray ? [] : {}; 548 - } 549 - 550 - current = current[key] as Record<string, unknown>; 551 - } 552 - 553 - const finalKey = keys[keys.length - 1]!; 554 - 555 - if (DANGEROUS_KEYS.has(finalKey)) { 556 - throw new Error(`Invalid key "${finalKey}"`); 557 - } 558 - 559 - current[finalKey] = value; 560 - }; 561 - 562 - const normalizeIssues = (issues: readonly StandardSchemaV1.Issue[]): FormIssue[] => { 563 - return issues.map((issue) => ({ 564 - path: (issue.path ?? []).map((segment) => (typeof segment === 'object' ? segment.key : segment)) as ( 565 - | string 566 - | number 567 - )[], 568 - message: issue.message, 569 - })); 570 - }; 571 - 572 - /** flattens issues into a map keyed by path prefix for O(1) lookups */ 573 - const flattenIssues = (issues: FormIssue[]): Record<string, FormIssue[]> => { 574 - const result: Record<string, FormIssue[]> = {}; 575 - 576 - for (const issue of issues) { 577 - (result.$ ??= []).push(issue); 578 - 579 - let name = ''; 580 - for (const key of issue.path) { 581 - if (typeof key === 'number') { 582 - name += `[${key}]`; 583 - } else { 584 - name += name === '' ? key : `.${key}`; 585 - } 586 - (result[name] ??= []).push(issue); 587 - } 588 - } 589 - 590 - return result; 591 - }; 592 - // #endregion
+48
packages/danaus/src/web/layouts/account.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 3 + import AsideItem from '../components/aside-item.tsx'; 4 + import Key2Outlined from '../icons/central/key-2-outlined.tsx'; 5 + import PersonOutlined from '../icons/central/person-outlined.tsx'; 6 + import ShieldOutlined from '../icons/central/shield-outlined.tsx'; 7 + import { routes } from '../routes.ts'; 8 + 9 + import { BaseLayout } from './base.tsx'; 10 + 11 + export interface AccountLayoutProps { 12 + children?: JSXNode; 13 + } 14 + 15 + /** 16 + * account management layout with sidebar navigation. 17 + */ 18 + export const AccountLayout = (props: AccountLayoutProps) => { 19 + return ( 20 + <BaseLayout> 21 + <div class="flex flex-col gap-4 p-4 sm:p-16 sm:pt-24 lg:grid lg:grid-cols-[280px_minmax(0,640px)] lg:justify-center"> 22 + <aside class="-ml-2 flex flex-col gap-4 sm:ml-0"> 23 + <div class="flex h-8 shrink-0 items-center pl-4"> 24 + <h2 class="text-base-400 font-medium">Account</h2> 25 + </div> 26 + 27 + <div class="flex flex-col gap-px"> 28 + <AsideItem href={routes.account.overview.href()} exact icon={<PersonOutlined size={20} />}> 29 + Overview 30 + </AsideItem> 31 + 32 + <AsideItem href={routes.account.appPasswords.href()} icon={<Key2Outlined size={20} />}> 33 + App passwords 34 + </AsideItem> 35 + 36 + <AsideItem href={routes.account.security.href()} icon={<ShieldOutlined size={20} />}> 37 + Security 38 + </AsideItem> 39 + </div> 40 + </aside> 41 + 42 + <hr class="border-neutral-stroke-1 sm:hidden" /> 43 + 44 + <main>{props.children}</main> 45 + </div> 46 + </BaseLayout> 47 + ); 48 + };
+41
packages/danaus/src/web/layouts/admin.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 3 + import AsideItem from '../components/aside-item.tsx'; 4 + import Group1Outlined from '../icons/central/group-1-outlined.tsx'; 5 + import HomeOpenOutlined from '../icons/central/home-open-outlined.tsx'; 6 + import { routes } from '../routes.ts'; 7 + 8 + import { BaseLayout } from './base.tsx'; 9 + 10 + export interface AdminLayoutProps { 11 + children?: JSXNode; 12 + } 13 + 14 + /** 15 + * admin layout with sidebar navigation. 16 + */ 17 + export const AdminLayout = (props: AdminLayoutProps) => { 18 + return ( 19 + <BaseLayout> 20 + <div class="flex flex-col gap-4 p-4 sm:p-16 sm:pt-24 lg:grid lg:grid-cols-[280px_minmax(0,640px)] lg:justify-center"> 21 + <aside class="-ml-2 flex flex-col gap-2 sm:ml-0"> 22 + <h2 class="pb-2 pl-4 text-base-400 font-medium">PDS administration</h2> 23 + 24 + <div class="flex flex-col gap-px"> 25 + <AsideItem href={routes.admin.dashboard.href()} exact icon={<HomeOpenOutlined size={20} />}> 26 + Home 27 + </AsideItem> 28 + 29 + <AsideItem href={routes.admin.accounts.index.href()} icon={<Group1Outlined size={20} />}> 30 + Accounts 31 + </AsideItem> 32 + </div> 33 + </aside> 34 + 35 + <hr class="border-neutral-stroke-1 sm:hidden" /> 36 + 37 + <main>{props.children}</main> 38 + </div> 39 + </BaseLayout> 40 + ); 41 + };
+29
packages/danaus/src/web/layouts/base.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 3 + import { IdProvider } from '../components/id.tsx'; 4 + 5 + export interface BaseLayoutProps { 6 + children?: JSXNode; 7 + } 8 + 9 + /** 10 + * base HTML layout wrapper for all pages. 11 + * includes the document structure, meta tags, and stylesheet. 12 + */ 13 + export const BaseLayout = (props: BaseLayoutProps) => { 14 + return ( 15 + <IdProvider> 16 + <html lang="en"> 17 + <head> 18 + <meta charset="utf-8" /> 19 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 20 + <link rel="stylesheet" href="/assets/style.css" /> 21 + </head> 22 + 23 + <body> 24 + <div class="flex min-h-dvh flex-col">{props.children}</div> 25 + </body> 26 + </html> 27 + </IdProvider> 28 + ); 29 + };
+33
packages/danaus/src/web/middlewares/app-context.ts
··· 1 + import { createInjectionKey, type Middleware } from '@oomfware/fetch-router'; 2 + import { getContext } from '@oomfware/fetch-router/middlewares/async-context'; 3 + 4 + import type { AppContext } from '#app/context.ts'; 5 + 6 + const appContextKey = createInjectionKey<AppContext>(); 7 + 8 + /** 9 + * middleware that provides the AppContext to the request context store. 10 + * @param ctx the application context to provide 11 + */ 12 + export const provideAppContext = (ctx: AppContext): Middleware => { 13 + return async ({ store }, next) => { 14 + store.provide(appContextKey, ctx); 15 + return next(); 16 + }; 17 + }; 18 + 19 + /** 20 + * retrieves the AppContext from the current request context. 21 + * must be called within a request handler after the provideAppContext middleware. 22 + * @returns the application context 23 + */ 24 + export const getAppContext = (): AppContext => { 25 + const { store } = getContext(); 26 + const ctx = store.inject(appContextKey); 27 + 28 + if (ctx === undefined) { 29 + throw new Error('AppContext not found in request context'); 30 + } 31 + 32 + return ctx; 33 + };
+32
packages/danaus/src/web/middlewares/basic-auth.ts
··· 1 + import type { Middleware } from '@oomfware/fetch-router'; 2 + 3 + import { parseBasicAuth } from '#app/auth/verifier.ts'; 4 + 5 + import { getAppContext } from './app-context.ts'; 6 + 7 + const REALM = 'admin'; 8 + 9 + /** 10 + * middleware that requires HTTP Basic Authentication for admin access. 11 + * uses the admin password from app config via async context. 12 + */ 13 + export const requireAdmin = (): Middleware => { 14 + return async ({ request }, next) => { 15 + const ctx = getAppContext(); 16 + const adminPassword = ctx.config.secrets.adminPassword; 17 + 18 + if (adminPassword === null) { 19 + return new Response('Administration UI is disabled', { status: 403 }); 20 + } 21 + 22 + const auth = parseBasicAuth(request); 23 + if (auth === null || auth.password !== adminPassword) { 24 + return new Response('Unauthorized', { 25 + status: 401, 26 + headers: { 'www-authenticate': `Basic realm="${REALM}", charset="UTF-8"` }, 27 + }); 28 + } 29 + 30 + return next(); 31 + }; 32 + };
+51
packages/danaus/src/web/middlewares/session.ts
··· 1 + import { createInjectionKey, redirect, type Middleware } from '@oomfware/fetch-router'; 2 + import { getContext } from '@oomfware/fetch-router/middlewares/async-context'; 3 + 4 + import type { WebSession } from '#app/accounts/manager.ts'; 5 + import { readWebSessionToken, verifyWebSessionToken } from '#app/auth/web.ts'; 6 + 7 + import { getAppContext } from './app-context.ts'; 8 + 9 + const sessionKey = createInjectionKey<WebSession>(); 10 + 11 + /** 12 + * middleware that requires a valid web session. 13 + * redirects to login page if no session is found. 14 + */ 15 + export const requireSession = (): Middleware => { 16 + return async ({ request, url, store }, next) => { 17 + const ctx = getAppContext(); 18 + const path = url.pathname; 19 + 20 + const token = readWebSessionToken(request); 21 + if (!token) { 22 + redirect(`/account/login?redirect=${encodeURIComponent(path)}`); 23 + } 24 + 25 + const sessionId = verifyWebSessionToken(ctx.config.secrets.jwtKey, token); 26 + if (!sessionId) { 27 + redirect(`/account/login?redirect=${encodeURIComponent(path)}`); 28 + } 29 + 30 + const session = ctx.accountManager.getWebSession(sessionId); 31 + if (!session) { 32 + redirect(`/account/login?redirect=${encodeURIComponent(path)}`); 33 + } 34 + 35 + store.provide(sessionKey, session); 36 + return next(); 37 + }; 38 + }; 39 + 40 + /** 41 + * retrieves the current web session from the request context. 42 + * must be called within a request handler after the requireSession middleware. 43 + * @returns the web session 44 + */ 45 + export const getSession = (): WebSession => { 46 + const session = getContext().store.inject(sessionKey); 47 + if (!session) { 48 + throw new Error('Session not found in request context'); 49 + } 50 + return session; 51 + };
-61
packages/danaus/src/web/oauth/index.tsx
··· 1 - import { Hono } from 'hono'; 2 - import { jsxRenderer } from 'hono/jsx-renderer'; 3 - 4 - import type { AppContext } from '#app/context.ts'; 5 - 6 - import { IdProvider } from '../components/id.tsx'; 7 - import Button from '../primitives/button.tsx'; 8 - 9 - export const createOAuthApp = (_ctx: AppContext) => { 10 - const app = new Hono(); 11 - 12 - // #region base HTML renderer 13 - app.use( 14 - jsxRenderer(({ children }) => { 15 - return ( 16 - <IdProvider> 17 - <html lang="en"> 18 - <head> 19 - <meta charset="utf-8" /> 20 - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 21 - <link rel="stylesheet" href="/assets/style.css" /> 22 - </head> 23 - 24 - <body> 25 - <div class="flex min-h-dvh flex-col">{children}</div> 26 - </body> 27 - </html> 28 - </IdProvider> 29 - ); 30 - }), 31 - ); 32 - // #endregion 33 - 34 - // #region authorize route 35 - app.on(['GET', 'POST'], '/authorize', (c) => { 36 - return c.render( 37 - <> 38 - <title>authorize - danaus</title> 39 - 40 - <div class="flex flex-1 items-center justify-center p-4"> 41 - <div class="w-full max-w-96 rounded-xl bg-neutral-background-1 p-6 shadow-16"> 42 - <div class="flex flex-col gap-4"> 43 - <h1 class="text-base-500 font-semibold">authorize application</h1> 44 - 45 - <p class="text-base-300 text-neutral-foreground-3"> 46 - OAuth authorization is not yet implemented. 47 - </p> 48 - 49 - <Button href="/account" variant="outlined"> 50 - Back to account 51 - </Button> 52 - </div> 53 - </div> 54 - </div> 55 - </>, 56 - ); 57 - }); 58 - // #endregion 59 - 60 - return app; 61 - };
+4 -3
packages/danaus/src/web/primitives/accordion-header.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 import ChevronDownSmallOutlined from '../icons/central/chevron-down-small-outlined.tsx'; 5 6 ··· 51 52 size?: 'small' | 'medium' | 'large' | 'extra-large'; 52 53 expandIconPosition?: 'start' | 'end'; 53 54 /** slot for custom icon before the text */ 54 - icon?: Child; 55 + icon?: JSXNode; 55 56 class?: string; 56 - children?: Child; 57 + children?: JSXNode; 57 58 } 58 59 59 60 /**
+2 -2
packages/danaus/src/web/primitives/accordion-item.tsx
··· 1 - import type { Child } from 'hono/jsx'; 1 + import type { JSXNode } from '@oomfware/jsx'; 2 2 3 3 export interface AccordionItemProps { 4 4 /** whether the accordion item is open by default */ ··· 6 6 /** group name for exclusive accordion behavior (only one open at a time) */ 7 7 name?: string; 8 8 class?: string; 9 - children?: Child; 9 + children?: JSXNode; 10 10 } 11 11 12 12 /**
+3 -2
packages/danaus/src/web/primitives/accordion-panel.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 const root = cva({ 5 6 base: 'px-3 pb-3', ··· 7 8 8 9 export interface AccordionPanelProps { 9 10 class?: string; 10 - children?: Child; 11 + children?: JSXNode; 11 12 } 12 13 13 14 /**
+2 -2
packages/danaus/src/web/primitives/accordion.tsx
··· 1 - import type { Child } from 'hono/jsx'; 1 + import type { JSXNode } from '@oomfware/jsx'; 2 2 3 3 export interface AccordionProps { 4 4 class?: string; 5 - children?: Child; 5 + children?: JSXNode; 6 6 } 7 7 8 8 /**
+3 -2
packages/danaus/src/web/primitives/button.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva, type VariantProps } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 import type { InvokerCommand } from './utils/types.ts'; 5 6 ··· 57 58 /** invoker command action */ 58 59 command?: InvokerCommand; 59 60 class?: string; 60 - children?: Child; 61 + children?: JSXNode; 61 62 } 62 63 63 64 const Button = (props: ButtonProps) => {
+3 -2
packages/danaus/src/web/primitives/checkbox.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 import { useId } from '../components/id.tsx'; 5 6 import CheckmarkIcon from '../icons/central/checkmark-1-solid.tsx'; ··· 75 76 disabled?: boolean; 76 77 labelPosition?: 'before' | 'after'; 77 78 class?: string; 78 - children?: Child; 79 + children?: JSXNode; 79 80 } 80 81 81 82 const Checkbox = (props: CheckboxProps) => {
+3 -2
packages/danaus/src/web/primitives/dialog-actions.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva, type VariantProps } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 const root = cva({ 5 6 base: [ ··· 18 19 19 20 export interface DialogActionsProps extends VariantProps<typeof root> { 20 21 class?: string; 21 - children?: Child; 22 + children?: JSXNode; 22 23 } 23 24 24 25 /**
+3 -2
packages/danaus/src/web/primitives/dialog-body.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 const root = cva({ 5 6 base: ['grid gap-2', '@container/dialog-body'], ··· 7 8 8 9 export interface DialogBodyProps { 9 10 class?: string; 10 - children?: Child; 11 + children?: JSXNode; 11 12 } 12 13 13 14 /**
+2 -3
packages/danaus/src/web/primitives/dialog-close.tsx
··· 1 - import { cloneElement } from 'hono/jsx'; 2 - import type { JSX } from 'hono/jsx/jsx-runtime'; 1 + import { cloneElement, type JSXElement } from '@oomfware/jsx'; 3 2 4 3 import { useDialogContext } from './utils/dialog-context.tsx'; 5 4 6 5 export interface DialogCloseProps { 7 - children: JSX.Element; 6 + children: JSXElement; 8 7 } 9 8 10 9 const DialogClose = (props: DialogCloseProps) => {
+3 -2
packages/danaus/src/web/primitives/dialog-content.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 const root = cva({ 5 6 base: ['min-h-8 overflow-y-auto', 'text-base-300'], ··· 7 8 8 9 export interface DialogContentProps { 9 10 class?: string; 10 - children?: Child; 11 + children?: JSXNode; 11 12 } 12 13 13 14 /**
+3 -2
packages/danaus/src/web/primitives/dialog-surface.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva, type VariantProps } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 import { useDialogContext } from './utils/dialog-context'; 5 6 ··· 43 44 }); 44 45 45 46 export interface DialogSurfaceProps extends VariantProps<typeof surface> { 46 - children?: Child; 47 + children?: JSXNode; 47 48 } 48 49 49 50 /**
+4 -3
packages/danaus/src/web/primitives/dialog-title.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 import { useDialogContext } from './utils/dialog-context.tsx'; 5 6 ··· 13 14 14 15 export interface DialogTitleProps { 15 16 /** optional action element (e.g., close button) */ 16 - action?: Child; 17 + action?: JSXNode; 17 18 class?: string; 18 - children?: Child; 19 + children?: JSXNode; 19 20 } 20 21 21 22 /**
+2 -3
packages/danaus/src/web/primitives/dialog-trigger.tsx
··· 1 - import { cloneElement } from 'hono/jsx'; 2 - import type { JSX } from 'hono/jsx/jsx-runtime'; 1 + import { cloneElement, type JSXElement } from '@oomfware/jsx'; 3 2 4 3 import { useDialogContext } from './utils/dialog-context.tsx'; 5 4 6 5 export interface DialogTriggerProps { 7 - children: JSX.Element; 6 + children: JSXElement; 8 7 } 9 8 10 9 const DialogTrigger = (props: DialogTriggerProps) => {
+2 -2
packages/danaus/src/web/primitives/dialog.tsx
··· 1 - import type { Child } from 'hono/jsx'; 1 + import type { JSXNode } from '@oomfware/jsx'; 2 2 3 3 import { useId } from '../components/id.tsx'; 4 4 ··· 6 6 7 7 export interface DialogProps { 8 8 id?: string; 9 - children?: Child; 9 + children?: JSXNode; 10 10 } 11 11 12 12 /**
+8 -7
packages/danaus/src/web/primitives/field.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 import { useId } from '../components/id.tsx'; 5 6 import CheckCircle2Solid from '../icons/central/check-circle-2-solid.tsx'; ··· 61 62 export interface FieldProps { 62 63 required?: boolean; 63 64 validationStatus?: ValidationStatus; 64 - label?: Child; 65 - description?: Child; 66 - hint?: Child; 67 - validationMessageText?: Child; 68 - validationMessageIcon?: Child; 65 + label?: JSXNode; 66 + description?: JSXNode; 67 + hint?: JSXNode; 68 + validationMessageText?: JSXNode; 69 + validationMessageIcon?: JSXNode; 69 70 class?: string; 70 - children?: Child; 71 + children?: JSXNode; 71 72 } 72 73 73 74 const Field = (props: FieldProps) => {
+4 -3
packages/danaus/src/web/primitives/input.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 import { useFieldContext } from './utils/field-context.tsx'; 5 6 ··· 67 68 autofocus?: boolean; 68 69 autocomplete?: string; 69 70 required?: boolean; 70 - contentBefore?: Child; 71 - contentAfter?: Child; 71 + contentBefore?: JSXNode; 72 + contentAfter?: JSXNode; 72 73 class?: string; 73 74 } 74 75
+3 -2
packages/danaus/src/web/primitives/label.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 import { useFieldContext } from './utils/field-context.tsx'; 5 6 ··· 15 16 for?: string; 16 17 required?: boolean; 17 18 class?: string; 18 - children?: Child; 19 + children?: JSXNode; 19 20 } 20 21 21 22 const Label = (props: LabelProps) => {
+3 -2
packages/danaus/src/web/primitives/menu-item.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 import type { InvokerCommand } from './utils/types.ts'; 5 6 ··· 24 25 /** invoker command action */ 25 26 command?: InvokerCommand; 26 27 class?: string; 27 - children?: Child; 28 + children?: JSXNode; 28 29 } 29 30 30 31 /**
+3 -2
packages/danaus/src/web/primitives/menu-list.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 const root = cva({ 5 6 base: ['flex flex-col gap-0.5'], ··· 7 8 8 9 export interface MenuListProps { 9 10 class?: string; 10 - children?: Child; 11 + children?: JSXNode; 11 12 } 12 13 13 14 /**
+3 -2
packages/danaus/src/web/primitives/menu-popover.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 import { useMenuContext } from './utils/menu-context.tsx'; 5 6 ··· 12 13 13 14 export interface MenuPopoverProps { 14 15 class?: string; 15 - children?: Child; 16 + children?: JSXNode; 16 17 } 17 18 18 19 /**
+4 -4
packages/danaus/src/web/primitives/menu-trigger.tsx
··· 1 + import { cloneElement, type JSXElement } from '@oomfware/jsx'; 2 + 1 3 import { cx } from 'cva'; 2 - import { cloneElement } from 'hono/jsx'; 3 - import type { JSX } from 'hono/jsx/jsx-runtime'; 4 4 5 5 import { useMenuContext } from './utils/menu-context.tsx'; 6 6 7 7 export interface MenuTriggerProps { 8 - children: JSX.Element; 8 + children: JSXElement; 9 9 } 10 10 11 11 /** ··· 16 16 const { children } = props; 17 17 const { menuId } = useMenuContext(); 18 18 19 - const childProps = (children as any).props as Record<string, unknown> | undefined; 19 + const childProps = children.props as Record<string, unknown>; 20 20 21 21 return cloneElement(children, { 22 22 commandfor: menuId,
+4 -2
packages/danaus/src/web/primitives/menu.tsx
··· 1 - import { useId, type Child } from 'hono/jsx'; 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 3 + import { useId } from '../components/id.tsx'; 2 4 3 5 import { MenuContext, type MenuContextValue } from './utils/menu-context.tsx'; 4 6 5 7 export interface MenuProps { 6 8 id?: string; 7 - children?: Child; 9 + children?: JSXNode; 8 10 } 9 11 10 12 /**
+3 -2
packages/danaus/src/web/primitives/message-bar-actions.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 import { useMessageBarContext } from './utils/message-bar-context.tsx'; 5 6 ··· 15 16 16 17 export interface MessageBarActionsProps { 17 18 class?: string; 18 - children?: Child; 19 + children?: JSXNode; 19 20 } 20 21 21 22 /**
+3 -2
packages/danaus/src/web/primitives/message-bar-body.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 const root = cva({ 5 6 base: ['pr-3', 'text-base-300'], ··· 7 8 8 9 export interface MessageBarBodyProps { 9 10 class?: string; 10 - children?: Child; 11 + children?: JSXNode; 11 12 } 12 13 13 14 /**
+3 -2
packages/danaus/src/web/primitives/message-bar-title.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 const root = cva({ 5 6 base: ['mr-1', 'text-base-300 font-semibold'], ··· 7 8 8 9 export interface MessageBarTitleProps { 9 10 class?: string; 10 - children?: Child; 11 + children?: JSXNode; 11 12 } 12 13 13 14 /**
+5 -4
packages/danaus/src/web/primitives/message-bar.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 import CheckCircle2Solid from '../icons/central/check-circle-2-solid.tsx'; 5 6 import CircleInfoSolid from '../icons/central/circle-info-solid.tsx'; ··· 13 14 type MessageBarLayout, 14 15 } from './utils/message-bar-context.tsx'; 15 16 16 - const getIntentIcon = (intent: MessageBarIntent): Child => { 17 + const getIntentIcon = (intent: MessageBarIntent): JSXNode => { 17 18 switch (intent) { 18 19 case 'info': 19 20 return <CircleInfoSolid size={20} />; ··· 71 72 /** layout of the message bar */ 72 73 layout: MessageBarLayout; 73 74 /** optional icon to display */ 74 - icon?: Child; 75 + icon?: JSXNode; 75 76 class?: string; 76 - children?: Child; 77 + children?: JSXNode; 77 78 } 78 79 79 80 /**
+3 -3
packages/danaus/src/web/primitives/radio-group.tsx
··· 1 - import { createContext, useContext, type Child } from 'hono/jsx'; 1 + import { createContext, use, type JSXNode } from '@oomfware/jsx'; 2 2 3 3 import { useId } from '../components/id.tsx'; 4 4 ··· 13 13 export const RadioGroupContext = createContext<RadioGroupContextValue | null>(null); 14 14 15 15 export const useRadioGroupContext = () => { 16 - const context = useContext(RadioGroupContext); 16 + const context = use(RadioGroupContext); 17 17 if (context === null) { 18 18 throw new Error('<Radio> must be used under <RadioGroup>'); 19 19 } ··· 27 27 disabled?: boolean; 28 28 autofocus?: boolean; 29 29 class?: string; 30 - children?: Child; 30 + children?: JSXNode; 31 31 } 32 32 33 33 const RadioGroup = (props: RadioGroupProps) => {
+3 -2
packages/danaus/src/web/primitives/radio.tsx
··· 1 + import type { JSXNode } from '@oomfware/jsx'; 2 + 1 3 import { cva } from 'cva'; 2 - import type { Child } from 'hono/jsx'; 3 4 4 5 import { useId } from '../components/id.tsx'; 5 6 ··· 65 66 value: string; 66 67 disabled?: boolean; 67 68 class?: string; 68 - children?: Child; 69 + children?: JSXNode; 69 70 } 70 71 71 72 const Radio = (props: RadioProps) => {
+2 -2
packages/danaus/src/web/primitives/utils/dialog-context.tsx
··· 1 - import { createContext, useContext } from 'hono/jsx'; 1 + import { createContext, use } from '@oomfware/jsx'; 2 2 3 3 export interface DialogContextValue { 4 4 dialogId: string; ··· 15 15 (fallback: null): DialogContextValue | null; 16 16 (fallback?: DialogContextValue): DialogContextValue; 17 17 } = (fallback?: DialogContextValue | null): any => { 18 - const context = useContext(DialogContext); 18 + const context = use(DialogContext); 19 19 if (context === null) { 20 20 if (fallback !== undefined) { 21 21 return fallback;
+2 -2
packages/danaus/src/web/primitives/utils/field-context.tsx
··· 1 - import { createContext, useContext } from 'hono/jsx'; 1 + import { createContext, use } from '@oomfware/jsx'; 2 2 3 3 export type ValidationStatus = 'error' | 'warning' | 'success' | 'none'; 4 4 ··· 17 17 (fallback: null): FieldContextValue | null; 18 18 (fallback?: FieldContextValue): FieldContextValue; 19 19 } = (fallback?: FieldContextValue | null): any => { 20 - const context = useContext(FieldContext); 20 + const context = use(FieldContext); 21 21 if (context === null) { 22 22 if (fallback !== undefined) { 23 23 return fallback;
+2 -2
packages/danaus/src/web/primitives/utils/menu-context.tsx
··· 1 - import { createContext, useContext } from 'hono/jsx'; 1 + import { createContext, use } from '@oomfware/jsx'; 2 2 3 3 export interface MenuContextValue { 4 4 menuId: string; ··· 14 14 (fallback: null): MenuContextValue | null; 15 15 (fallback?: MenuContextValue): MenuContextValue; 16 16 } = (fallback?: MenuContextValue | null): any => { 17 - const context = useContext(MenuContext); 17 + const context = use(MenuContext); 18 18 if (context === null) { 19 19 if (fallback !== undefined) { 20 20 return fallback;
+2 -2
packages/danaus/src/web/primitives/utils/message-bar-context.tsx
··· 1 - import { createContext, useContext } from 'hono/jsx'; 1 + import { createContext, use } from '@oomfware/jsx'; 2 2 3 3 export type MessageBarIntent = 'info' | 'success' | 'warning' | 'error'; 4 4 export type MessageBarLayout = 'singleline' | 'multiline'; ··· 18 18 (fallback: null): MessageBarContextValue | null; 19 19 (fallback?: MessageBarContextValue): MessageBarContextValue; 20 20 } = (fallback?: MessageBarContextValue | null): any => { 21 - const context = useContext(MessageBarContext); 21 + const context = use(MessageBarContext); 22 22 if (context === null) { 23 23 if (fallback !== undefined) { 24 24 return fallback;
+31
packages/danaus/src/web/router.ts
··· 1 + import { createRouter } from '@oomfware/fetch-router'; 2 + import { asyncContext } from '@oomfware/fetch-router/middlewares/async-context'; 3 + 4 + import type { AppContext } from '#app/context.ts'; 5 + 6 + import accountController from './controllers/account.tsx'; 7 + import adminController from './controllers/admin.tsx'; 8 + import homeController from './controllers/home.tsx'; 9 + import loginController from './controllers/login.tsx'; 10 + import oauthController from './controllers/oauth.tsx'; 11 + import { provideAppContext } from './middlewares/app-context.ts'; 12 + import { routes } from './routes.ts'; 13 + 14 + /** 15 + * creates the web router with all routes and middleware. 16 + * @param ctx application context 17 + * @returns the configured router 18 + */ 19 + export const createWebRouter = (ctx: AppContext) => { 20 + const router = createRouter({ 21 + middleware: [asyncContext(), provideAppContext(ctx)], 22 + }); 23 + 24 + router.map(routes.home, homeController); 25 + router.map(routes.admin, adminController); 26 + router.map(routes.login, loginController); 27 + router.map(routes.account, accountController); 28 + router.map(routes.oauth, oauthController); 29 + 30 + return router; 31 + };
+27
packages/danaus/src/web/routes.ts
··· 1 + import { route } from '@oomfware/fetch-router'; 2 + 3 + export const routes = route({ 4 + home: '/', 5 + 6 + admin: { 7 + dashboard: '/admin', 8 + accounts: { 9 + index: '/admin/accounts', 10 + create: '/admin/accounts/new', 11 + }, 12 + }, 13 + 14 + // login is separate - no session required 15 + login: '/account/login', 16 + 17 + // account routes - all require session 18 + account: { 19 + overview: '/account', 20 + appPasswords: '/account/app-passwords', 21 + security: '/account/security', 22 + }, 23 + 24 + oauth: { 25 + authorize: '/oauth/authorize', 26 + }, 27 + });
-92
packages/danaus/src/web/styles/main.out.css
··· 322 322 .-mx-1\.25 { 323 323 margin-inline: calc(var(--spacing) * -1.25); 324 324 } 325 - .-mx-3 { 326 - margin-inline: calc(var(--spacing) * -3); 327 - } 328 325 .-my-0\.5 { 329 326 margin-block: calc(var(--spacing) * -0.5); 330 327 } ··· 517 514 } 518 515 .flex-col { 519 516 flex-direction: column; 520 - } 521 - .flex-row-reverse { 522 - flex-direction: row-reverse; 523 517 } 524 518 .flex-nowrap { 525 519 flex-wrap: nowrap; ··· 833 827 --tw-font-weight: var(--font-weight-semibold); 834 828 font-weight: var(--font-weight-semibold); 835 829 } 836 - .wrap-anywhere { 837 - overflow-wrap: anywhere; 838 - } 839 830 .wrap-break-word { 840 831 overflow-wrap: break-word; 841 832 } ··· 900 891 .outline-transparent { 901 892 outline-color: transparent; 902 893 } 903 - .filter { 904 - filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); 905 - } 906 894 .transition { 907 895 transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events; 908 896 transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); 909 897 transition-duration: var(--tw-duration, var(--default-transition-duration)); 910 898 } 911 - .transition-transform { 912 - transition-property: transform, translate, scale, rotate; 913 - transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); 914 - transition-duration: var(--tw-duration, var(--default-transition-duration)); 915 - } 916 899 .duration-100 { 917 900 --tw-duration: 100ms; 918 901 transition-duration: 100ms; ··· 925 908 --tw-outline-style: none; 926 909 outline-style: none; 927 910 } 928 - .select-all { 929 - -webkit-user-select: all; 930 - user-select: all; 931 - } 932 911 .select-none { 933 912 -webkit-user-select: none; 934 913 user-select: none; ··· 950 929 } 951 930 .try-flip-y { 952 931 position-try-fallbacks: flip-block; 953 - } 954 - .group-open\/accordion-item\:rotate-180 { 955 - &:is(:where(.group\/accordion-item):is([open], :popover-open, :open) *) { 956 - rotate: 180deg; 957 - } 958 932 } 959 933 .group-hover\/checkbox\:border-compound-brand-background-hover { 960 934 &:is(:where(.group\/checkbox):hover *) { ··· 1711 1685 inherits: false; 1712 1686 initial-value: solid; 1713 1687 } 1714 - @property --tw-blur { 1715 - syntax: "*"; 1716 - inherits: false; 1717 - } 1718 - @property --tw-brightness { 1719 - syntax: "*"; 1720 - inherits: false; 1721 - } 1722 - @property --tw-contrast { 1723 - syntax: "*"; 1724 - inherits: false; 1725 - } 1726 - @property --tw-grayscale { 1727 - syntax: "*"; 1728 - inherits: false; 1729 - } 1730 - @property --tw-hue-rotate { 1731 - syntax: "*"; 1732 - inherits: false; 1733 - } 1734 - @property --tw-invert { 1735 - syntax: "*"; 1736 - inherits: false; 1737 - } 1738 - @property --tw-opacity { 1739 - syntax: "*"; 1740 - inherits: false; 1741 - } 1742 - @property --tw-saturate { 1743 - syntax: "*"; 1744 - inherits: false; 1745 - } 1746 - @property --tw-sepia { 1747 - syntax: "*"; 1748 - inherits: false; 1749 - } 1750 - @property --tw-drop-shadow { 1751 - syntax: "*"; 1752 - inherits: false; 1753 - } 1754 - @property --tw-drop-shadow-color { 1755 - syntax: "*"; 1756 - inherits: false; 1757 - } 1758 - @property --tw-drop-shadow-alpha { 1759 - syntax: "<percentage>"; 1760 - inherits: false; 1761 - initial-value: 100%; 1762 - } 1763 - @property --tw-drop-shadow-size { 1764 - syntax: "*"; 1765 - inherits: false; 1766 - } 1767 1688 @property --tw-duration { 1768 1689 syntax: "*"; 1769 1690 inherits: false; ··· 1793 1714 --tw-ring-offset-color: #fff; 1794 1715 --tw-ring-offset-shadow: 0 0 #0000; 1795 1716 --tw-outline-style: solid; 1796 - --tw-blur: initial; 1797 - --tw-brightness: initial; 1798 - --tw-contrast: initial; 1799 - --tw-grayscale: initial; 1800 - --tw-hue-rotate: initial; 1801 - --tw-invert: initial; 1802 - --tw-opacity: initial; 1803 - --tw-saturate: initial; 1804 - --tw-sepia: initial; 1805 - --tw-drop-shadow: initial; 1806 - --tw-drop-shadow-color: initial; 1807 - --tw-drop-shadow-alpha: 100%; 1808 - --tw-drop-shadow-size: initial; 1809 1717 --tw-duration: initial; 1810 1718 --tw-ease: initial; 1811 1719 }
+1 -1
packages/danaus/tsconfig.json
··· 16 16 17 17 /* JSX */ 18 18 "jsx": "react-jsx", 19 - "jsxImportSource": "hono/jsx", 19 + "jsxImportSource": "@oomfware/jsx", 20 20 21 21 /* Linting */ 22 22 "strict": true,
+40 -13
pnpm-lock.yaml
··· 97 97 '@kelinci/danaus-lexicons': 98 98 specifier: workspace:* 99 99 version: link:../lexicons 100 + '@oomfware/fetch-router': 101 + specifier: ^0.2.1 102 + version: 0.2.1 103 + '@oomfware/forms': 104 + specifier: ^0.2.0 105 + version: 0.2.0(@oomfware/fetch-router@0.2.1) 106 + '@oomfware/jsx': 107 + specifier: ^0.1.4 108 + version: 0.1.4 100 109 cva: 101 110 specifier: 1.0.0-beta.4 102 111 version: 1.0.0-beta.4(typescript@5.9.3) ··· 106 115 get-port: 107 116 specifier: ^7.1.0 108 117 version: 7.1.0 109 - hono: 110 - specifier: ^4.11.3 111 - version: 4.11.3 112 118 jose: 113 119 specifier: ^6.1.3 114 120 version: 6.1.3 ··· 878 884 '@noble/secp256k1@3.0.0': 879 885 resolution: {integrity: sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==} 880 886 887 + '@oomfware/fetch-router@0.2.1': 888 + resolution: {integrity: sha512-WV0cSeKjyTmM2pXYlRzv1md3Dym1vMR8PnJ/GfZUg8i1GS7RIDezmMkqVaWI/9IpeOHhs+QeDO41q1u+z1EzSg==} 889 + 890 + '@oomfware/forms@0.2.0': 891 + resolution: {integrity: sha512-XNvTZzAAur4ahitZ5R5VSZSzJem9Myn1T5Vhv6RLhVALn8qTsOKD8ju+hYed9P/cdMGog4DhKpiyaXbS5Elicw==} 892 + peerDependencies: 893 + '@oomfware/fetch-router': ^0.2.1 894 + 895 + '@oomfware/jsx@0.1.4': 896 + resolution: {integrity: sha512-3mY2Iqdjl+mE1ni3i6x9TdmgYTndfgiK4hpqBpHfvZHLtUddLBPWT8+AEidC5EZRXS1E2B1AZvtHFPESdkscfQ==} 897 + 881 898 '@optique/core@0.6.11': 882 899 resolution: {integrity: sha512-GVLFihzBA1j78NFlkU5N1Lu0jRqET0k6Z66WK8VQKG/a3cxmCInVGSKMIdQG8i6pgC8wD5OizF6Y3QMztmhAxg==} 883 900 engines: {bun: '>=1.2.0', deno: '>=2.3.0', node: '>=20.0.0'} ··· 1237 1254 1238 1255 '@protobufjs/utf8@1.1.0': 1239 1256 resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} 1257 + 1258 + '@remix-run/route-pattern@0.16.0': 1259 + resolution: {integrity: sha512-Co6bPtODF7cLYVBweayRXfEb31ybz45WqwT/u72eDQJZgRSVKFf0Ps9fqinSaiX0Xp7jvkRCBAbSUgLuLLjzuw==} 1240 1260 1241 1261 '@standard-schema/spec@1.1.0': 1242 1262 resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} ··· 2073 2093 hmac-drbg@1.0.1: 2074 2094 resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} 2075 2095 2076 - hono@4.11.3: 2077 - resolution: {integrity: sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w==} 2078 - engines: {node: '>=16.9.0'} 2079 - 2080 2096 http-errors@2.0.1: 2081 2097 resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} 2082 2098 engines: {node: '>= 0.8'} ··· 2101 2117 resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} 2102 2118 engines: {node: '>=0.10.0'} 2103 2119 2104 - iconv-lite@0.7.1: 2105 - resolution: {integrity: sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==} 2120 + iconv-lite@0.7.2: 2121 + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} 2106 2122 engines: {node: '>=0.10.0'} 2107 2123 2108 2124 ieee754@1.2.1: ··· 3886 3902 3887 3903 '@noble/secp256k1@3.0.0': {} 3888 3904 3905 + '@oomfware/fetch-router@0.2.1': 3906 + dependencies: 3907 + '@remix-run/route-pattern': 0.16.0 3908 + 3909 + '@oomfware/forms@0.2.0(@oomfware/fetch-router@0.2.1)': 3910 + dependencies: 3911 + '@oomfware/fetch-router': 0.2.1 3912 + '@standard-schema/spec': 1.1.0 3913 + 3914 + '@oomfware/jsx@0.1.4': {} 3915 + 3889 3916 '@optique/core@0.6.11': {} 3890 3917 3891 3918 '@optique/run@0.6.11': ··· 4120 4147 '@protobufjs/pool@1.1.0': {} 4121 4148 4122 4149 '@protobufjs/utf8@1.1.0': {} 4150 + 4151 + '@remix-run/route-pattern@0.16.0': {} 4123 4152 4124 4153 '@standard-schema/spec@1.1.0': {} 4125 4154 ··· 4888 4917 minimalistic-assert: 1.0.1 4889 4918 minimalistic-crypto-utils: 1.0.1 4890 4919 4891 - hono@4.11.3: {} 4892 - 4893 4920 http-errors@2.0.1: 4894 4921 dependencies: 4895 4922 depd: 2.0.0 ··· 4927 4954 dependencies: 4928 4955 safer-buffer: 2.1.2 4929 4956 4930 - iconv-lite@0.7.1: 4957 + iconv-lite@0.7.2: 4931 4958 dependencies: 4932 4959 safer-buffer: 2.1.2 4933 4960 ··· 5633 5660 '@js-joda/core': 5.6.5 5634 5661 '@types/node': 22.19.3 5635 5662 bl: 6.1.6 5636 - iconv-lite: 0.7.1 5663 + iconv-lite: 0.7.2 5637 5664 js-md4: 0.3.2 5638 5665 native-duplexpair: 1.0.0 5639 5666 sprintf-js: 1.1.3