+1
.prettierrc
+1
.prettierrc
+3
-1
packages/danaus/package.json
+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
+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
+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
-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
+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
+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
-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
-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
+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
+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
+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
+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
+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
+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
-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
+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
+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
+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
+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
+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
+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
-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
+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
+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
+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
+2
-2
packages/danaus/src/web/primitives/accordion.tsx
+3
-2
packages/danaus/src/web/primitives/checkbox.tsx
+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
+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
+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
+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
+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
+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
+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
+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
+2
-2
packages/danaus/src/web/primitives/dialog.tsx
+8
-7
packages/danaus/src/web/primitives/field.tsx
+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
+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
+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/message-bar-actions.tsx
+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
+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
+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
+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
+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
+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
+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
+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/message-bar-context.tsx
+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
+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
+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
-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
+1
-1
packages/danaus/tsconfig.json
+40
-13
pnpm-lock.yaml
+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