@axium/server 0.37.1 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/acl.d.ts CHANGED
@@ -2,11 +2,14 @@ import { type AccessControl, type AccessControllable, type AccessTarget, type Us
2
2
  import type * as kysely from 'kysely';
3
3
  import type { WithRequired } from 'utilium';
4
4
  import * as db from './database.js';
5
+ export interface DBAccessControllable extends Omit<AccessControllable, 'id'> {
6
+ id: string | kysely.Generated<string>;
7
+ }
5
8
  type _TableNames = keyof {
6
9
  [K in keyof db.Schema as db.Schema[K] extends db.DBAccessControl ? K : never]: null;
7
10
  };
8
11
  type _TargetNames = keyof db.Schema & keyof {
9
- [K in keyof db.Schema as db.Schema[K] extends AccessControllable ? (`acl.${K}` extends keyof db.Schema ? K : never) : never]: null;
12
+ [K in keyof db.Schema as db.Schema[K] extends DBAccessControllable ? `acl.${K}` extends keyof db.Schema ? K : never : never]: null;
10
13
  };
11
14
  /**
12
15
  * `never` causes a ton of problems, so we use `string` if none of the tables are shareable.
@@ -28,16 +31,19 @@ export interface ACLSelectionOptions {
28
31
  /** Instead of using the `id` from `table`, use the `id` from this instead */
29
32
  alias?: string;
30
33
  }
34
+ /** Match ACL entries, optionally selecting for a given user-like object */
35
+ export declare function match(user?: Pick<UserInternal, 'id' | 'roles' | 'tags'>): (eb: kysely.ExpressionBuilder<db.Schema, any>) => kysely.ExpressionWrapper<db.Schema, any, kysely.SqlBool>;
31
36
  /**
32
37
  * Helper to select all access controls for a given table, including the user information.
33
38
  * Optionally filter for the entries applicable to a specific user.
34
39
  * This includes entries matching the user's ID, roles, or tags along with the "public" entry where all three "target" columns are null.
35
40
  */
36
- export declare function from<const TB extends TargetName>(table: TB, opt?: ACLSelectionOptions): (eb: kysely.ExpressionBuilder<db.Schema, any>) => kysely.AliasedRawBuilder<Result<`acl.${TB}`>[], 'acl'>;
41
+ export declare function from<const TB extends TargetName, const DB = db.Schema>(table: TB, opt?: ACLSelectionOptions): (eb: kysely.ExpressionBuilder<DB, any>) => kysely.AliasedRawBuilder<Result<`acl.${TB}`>[], 'acl'>;
37
42
  export declare function get<const TB extends TableName>(table: TB, itemId: string): Promise<WithRequired<Result<TB>, 'user'>[]>;
38
43
  export declare function update<const TB extends TableName>(table: TB, itemId: string, target: AccessTarget, permissions: PermissionsFor<TB>): Promise<Result<TB>>;
39
44
  export declare function remove<const TB extends TableName>(table: TB, itemId: string, target: AccessTarget): Promise<Result<TB>>;
40
45
  export declare function add<const TB extends TableName>(table: TB, itemId: string, target: AccessTarget): Promise<Result<TB>>;
46
+ /** Check an ACL against a set of permissions. */
41
47
  export declare function check<const TB extends TableName>(acl: Result<TB>[], permissions: Partial<PermissionsFor<TB>>): Set<keyof PermissionsFor<TB>>;
42
48
  export declare function listTables(): Record<string, TableName>;
43
49
  export interface OptionsForWhere<TB extends TargetName> {
package/dist/acl.js CHANGED
@@ -1,6 +1,20 @@
1
1
  import { fromTarget } from '@axium/core';
2
2
  import { jsonArrayFrom } from 'kysely/helpers/postgres';
3
3
  import * as db from './database.js';
4
+ /** Match ACL entries, optionally selecting for a given user-like object */
5
+ export function match(user) {
6
+ return function (eb) {
7
+ const allNull = eb.and([eb('userId', 'is', null), eb('role', 'is', null), eb('tag', 'is', null)]);
8
+ if (!user)
9
+ return allNull;
10
+ const ors = [allNull, eb('userId', '=', user.id)];
11
+ if (user.roles.length)
12
+ ors.push(eb('role', 'in', user.roles));
13
+ if (user.tags.length)
14
+ ors.push(eb('tag', 'in', user.tags));
15
+ return eb.or(ors);
16
+ };
17
+ }
4
18
  /**
5
19
  * Helper to select all access controls for a given table, including the user information.
6
20
  * Optionally filter for the entries applicable to a specific user.
@@ -12,17 +26,7 @@ export function from(table, opt = {}) {
12
26
  .selectAll()
13
27
  .$if(!opt.user, qb => qb.select(db.userFromId))
14
28
  .whereRef('_acl.itemId', '=', `${opt.alias || table}.id`)
15
- .$if(!!opt.user, qb => qb.where(eb => {
16
- const allNull = eb.and([eb('userId', 'is', null), eb('role', 'is', null), eb('tag', 'is', null)]);
17
- if (!opt.user)
18
- return allNull;
19
- const ors = [allNull, eb('userId', '=', opt.user.id)];
20
- if (opt.user.roles.length)
21
- ors.push(eb('role', 'in', opt.user.roles));
22
- if (opt.user.tags.length)
23
- ors.push(eb('tag', 'in', opt.user.tags));
24
- return eb.or(ors);
25
- })))
29
+ .where(match(opt.user)))
26
30
  .$castTo()
27
31
  .as('acl');
28
32
  }
@@ -62,6 +66,7 @@ export async function add(table, itemId, target) {
62
66
  .$castTo()
63
67
  .executeTakeFirstOrThrow();
64
68
  }
69
+ /** Check an ACL against a set of permissions. */
65
70
  export function check(acl, permissions) {
66
71
  const allowed = new Set();
67
72
  const all = new Set(Object.keys(permissions));
@@ -91,14 +96,7 @@ export function existsIn(table, user, options = {}) {
91
96
  .selectFrom(`acl.${table}`)
92
97
  // @ts-expect-error 2349
93
98
  .whereRef('itemId', '=', `${options.alias || `public.${table}`}.${options.itemId || 'id'}`)
94
- .where((eb) => {
95
- const ors = [eb('userId', '=', user.id)];
96
- if (user.roles.length)
97
- ors.push(eb('role', 'in', user.roles));
98
- if (user.tags.length)
99
- ors.push(eb('tag', 'in', user.tags));
100
- return eb.or(ors);
101
- }));
99
+ .where(match(user)));
102
100
  }
103
101
  /**
104
102
  * Use in a `where` to filter by items a user has access to
package/dist/api/admin.js CHANGED
@@ -1,12 +1,14 @@
1
+ import client from '@axium/client/package.json' with { type: 'json' };
1
2
  import { AuditFilter, Severity, UserAdminChange } from '@axium/core';
2
3
  import { debug, errorText, writeJSON } from '@axium/core/node/io';
3
- import { getVersionInfo } from '@axium/core/node/packages';
4
+ import core from '@axium/core/package.json' with { type: 'json' };
4
5
  import { _findPlugin, plugins, PluginUpdate, serverConfigs } from '@axium/core/plugins';
5
6
  import { jsonObjectFrom } from 'kysely/helpers/postgres';
6
7
  import { mkdirSync } from 'node:fs';
7
8
  import { dirname } from 'node:path/posix';
8
9
  import { deepAssign, omit } from 'utilium';
9
10
  import * as z from 'zod';
11
+ import $pkg from '../../package.json' with { type: 'json' };
10
12
  import { audit, events, getEvents } from '../audit.js';
11
13
  import { createVerification, requireSession } from '../auth.js';
12
14
  import { config } from '../config.js';
@@ -40,9 +42,9 @@ addRoute({
40
42
  configFiles: config.files.size,
41
43
  plugins: plugins.size,
42
44
  versions: {
43
- server: await getVersionInfo('@axium/server'),
44
- core: await getVersionInfo('@axium/core'),
45
- client: await getVersionInfo('@axium/client'),
45
+ server: $pkg.version,
46
+ core: core.version,
47
+ client: client.version,
46
48
  },
47
49
  };
48
50
  },
@@ -51,9 +53,7 @@ addRoute({
51
53
  path: '/api/admin/plugins',
52
54
  async GET(req) {
53
55
  await assertAdmin(this, req);
54
- return await Array.fromAsync(plugins
55
- .values()
56
- .map(async (p) => Object.assign(omit(p, '_hooks', '_client'), p.update_checks ? await getVersionInfo(p.specifier, p.loadedBy) : { latest: null })));
56
+ return await Array.fromAsync(plugins.values().map(p => omit(p, '_hooks', '_client')));
57
57
  },
58
58
  async POST(req) {
59
59
  await assertAdmin(this, req);
@@ -64,7 +64,7 @@ async function POST(request) {
64
64
  deviceType: registrationInfo.credentialDeviceType,
65
65
  backedUp: registrationInfo.credentialBackedUp,
66
66
  }).catch(withError('Failed to create passkey', 500));
67
- return await createSessionData(userId);
67
+ return await createSessionData(userId, request);
68
68
  }
69
69
  addRoute({
70
70
  path: '/api/register',
package/dist/api/users.js CHANGED
@@ -100,7 +100,7 @@ addRoute({
100
100
  path: '/api/users/:id/auth',
101
101
  params,
102
102
  async OPTIONS(request, { id: userId }) {
103
- const { type } = await parseBody(request, UserAuthOptions);
103
+ const { type, client } = await parseBody(request, UserAuthOptions);
104
104
  const user = await getUser(userId).catch(withError('User does not exist', 404));
105
105
  if (user.isSuspended)
106
106
  error(403, 'User is suspended');
@@ -111,7 +111,7 @@ addRoute({
111
111
  rpID: config.auth.rp_id,
112
112
  allowCredentials: passkeys.map(passkey => pick(passkey, 'id', 'transports')),
113
113
  });
114
- challenges.set(userId, { data: options.challenge, type });
114
+ challenges.set(userId, { data: options.challenge, type, client });
115
115
  return options;
116
116
  },
117
117
  async POST(request, { id: userId }) {
@@ -119,7 +119,7 @@ addRoute({
119
119
  const auth = challenges.get(userId);
120
120
  if (!auth)
121
121
  error(404, 'No challenge');
122
- const { data: expectedChallenge, type } = auth;
122
+ const { data: expectedChallenge, type, client } = auth;
123
123
  challenges.delete(userId);
124
124
  const passkey = await getPasskey(response.id).catch(withError('Passkey does not exist', 404));
125
125
  if (passkey.userId !== userId)
@@ -137,14 +137,13 @@ addRoute({
137
137
  error(401, 'Verification failed');
138
138
  switch (type) {
139
139
  case 'login':
140
- return await createSessionData(userId);
140
+ return await createSessionData(userId, request);
141
141
  case 'client_login':
142
- /** @todo tag in DB so users can manage easier */
143
- return await createSessionData(userId, { noCookie: true });
142
+ return await createSessionData(userId, request, { noCookie: true, clientUA: client });
144
143
  case 'action':
145
144
  if ((Date.now() - passkey.createdAt.getTime()) / 60_000 < config.auth.passkey_probation)
146
145
  error(403, 'You can not authorize sensitive actions with a newly created passkey');
147
- return await createSessionData(userId, { elevated: true });
146
+ return await createSessionData(userId, request, { elevated: true });
148
147
  }
149
148
  },
150
149
  });
@@ -266,6 +265,6 @@ addRoute({
266
265
  async POST(request, { id: userId }) {
267
266
  const { token } = await parseBody(request, z.object({ token: z.string() }));
268
267
  await useVerification('login', userId, token).catch(withError('Invalid or expired verification token', 400));
269
- return await createSessionData(userId);
268
+ return await createSessionData(userId, request);
270
269
  },
271
270
  });
package/dist/auth.d.ts CHANGED
@@ -8,7 +8,7 @@ export declare function updateUser({ id, ...user }: WithRequired<Insertable<Sche
8
8
  export interface SessionInternal extends Session {
9
9
  token: string;
10
10
  }
11
- export declare function createSession(userId: string, elevated?: boolean): Promise<SessionInternal>;
11
+ export declare function createSession(userId: string, name: string | null, elevated?: boolean): Promise<SessionInternal>;
12
12
  export interface SessionAndUser extends SessionInternal {
13
13
  user: UserInternal;
14
14
  }
package/dist/auth.js CHANGED
@@ -13,10 +13,11 @@ export async function updateUser({ id, ...user }) {
13
13
  }
14
14
  const in30days = () => new Date(Date.now() + 2592000000);
15
15
  const in10minutes = () => new Date(Date.now() + 600000);
16
- export async function createSession(userId, elevated = false) {
16
+ export async function createSession(userId, name, elevated = false) {
17
17
  const session = {
18
18
  id: randomUUID(),
19
19
  userId,
20
+ name,
20
21
  token: randomBytes(64).toString('base64'),
21
22
  expires: elevated ? in10minutes() : in30days(),
22
23
  elevated,
@@ -117,7 +118,7 @@ export async function authSessionForItem(itemType, itemId, permissions, session,
117
118
  .selectFrom(itemType)
118
119
  .selectAll()
119
120
  .where('id', '=', itemId)
120
- .$if(!!userId, eb => eb.select(acl.from(itemType, { user })))
121
+ .select(acl.from(itemType, { user }))
121
122
  .executeTakeFirstOrThrow()
122
123
  .catch(e => {
123
124
  if (e.message.includes('no rows'))
@@ -137,33 +138,40 @@ export async function authSessionForItem(itemType, itemId, permissions, session,
137
138
  if (userId == item.userId)
138
139
  return result;
139
140
  result.fromACL = true;
140
- let current = item;
141
- for (let i = 0; i < 25; i++) {
142
- try {
143
- if (!current.acl || !current.acl.length)
144
- error(403, 'Item is not shared with you');
145
- const missing = Array.from(acl.check(current.acl, permissions));
146
- if (missing.length)
147
- error(403, 'Missing permissions: ' + missing.join(', '));
148
- return result;
149
- }
150
- catch (e) {
151
- if (!current.parentId || !recursive)
141
+ const matchingControls = recursive
142
+ ? await db
143
+ .withRecursive('parents', qc => qc.selectFrom(itemType)
144
+ .select(['id', 'parentId'])
145
+ .$castTo()
146
+ .select(acl.from(itemType, { user }))
147
+ .select(eb => eb.lit(0).as('depth'))
148
+ .where('id', '=', itemId)
149
+ .unionAll(qc.selectFrom(`${itemType} as item`)
150
+ .select(['item.id', 'item.parentId'])
151
+ .innerJoin('parents as p', 'item.id', 'p.parentId')
152
+ .select(eb => eb(eb.ref('p.depth'), '+', eb.lit(1)).as('depth'))
153
+ .select(acl.from(itemType, { user, alias: 'item' }))))
154
+ .selectFrom('parents')
155
+ .select('acl')
156
+ .execute()
157
+ .then(parents => parents.flatMap(p => p.acl))
158
+ .catch(e => {
159
+ if (!(e instanceof Error))
152
160
  throw e;
153
- current = (await db
154
- .selectFrom(itemType)
155
- .selectAll()
156
- .where('id', '=', current.parentId)
157
- .$if(!!userId, eb => eb.select(acl.from(itemType, { user })))
158
- .executeTakeFirstOrThrow()
159
- .catch(e => {
160
- if (e.message.includes('no rows'))
161
- error(404, itemType + ' not found');
162
- throw e;
163
- }));
164
- }
165
- }
166
- error(403, 'You do not have permissions for any of the last 25 parent items');
161
+ switch (e.message) {
162
+ case 'column "parentId" does not exist':
163
+ error(500, `${itemType} does not support recursive ACLs`);
164
+ default:
165
+ throw e;
166
+ }
167
+ })
168
+ : item.acl;
169
+ if (!matchingControls.length)
170
+ error(403, 'Item is not shared with you');
171
+ const missing = Array.from(acl.check(matchingControls, permissions));
172
+ if (missing.length)
173
+ error(403, 'Missing permissions: ' + missing.join(', '));
174
+ return result;
167
175
  }
168
176
  /**
169
177
  * Authenticate a request against an "item" which has an ACL table.
@@ -29,7 +29,7 @@ export type TablesMatching<T> = (string & keyof Schema) & keyof {
29
29
  */
30
30
  export declare function userFromId<TB extends TablesMatching<{
31
31
  userId: string;
32
- }>>(builder: kysely.ExpressionBuilder<Schema, TB>): kysely.AliasedRawBuilder<UserInternal, 'user' | TB>;
32
+ }>, const DB extends Schema = Schema>(builder: kysely.ExpressionBuilder<DB, TB>): kysely.AliasedRawBuilder<UserInternal, 'user' | TB>;
33
33
  /**
34
34
  * Used for `update ... set ... from`
35
35
  */
@@ -77,6 +77,16 @@
77
77
  }
78
78
  }
79
79
  }
80
+ },
81
+ {
82
+ "delta": true,
83
+ "alter_tables": {
84
+ "sessions": {
85
+ "add_columns": {
86
+ "name": { "type": "text" }
87
+ }
88
+ }
89
+ }
80
90
  }
81
91
  ],
82
92
  "wipe": ["users", "verifications", "passkeys", "sessions", "audit_log"]
@@ -35,11 +35,13 @@ export declare function json(data: object, init?: ResponseInit): Response;
35
35
  export declare function parseBody<const Schema extends z.ZodType>(request: Request, schema: Schema): Promise<z.infer<Schema>>;
36
36
  export declare function parseSearch<const Schema extends z.ZodType>(request: Request, schema: Schema): z.infer<Schema>;
37
37
  export declare function getToken(request: Request, sensitive?: boolean): string | undefined;
38
+ export declare function getPrettyUA(request: Request, uaOverride?: string): Promise<string | null>;
38
39
  export interface CreateSessionOptions {
39
40
  elevated?: boolean;
40
41
  noCookie?: boolean;
42
+ clientUA?: string;
41
43
  }
42
- export declare function createSessionData(userId: string, { elevated, noCookie }?: CreateSessionOptions): Promise<Response>;
44
+ export declare function createSessionData(userId: string, request: Request, { elevated, noCookie, clientUA }?: CreateSessionOptions): Promise<Response>;
43
45
  export declare function stripUser(user: UserInternal, includeProtected?: boolean): User;
44
46
  export declare function withError(text: string, code?: number): (e: Error | ResponseError) => never;
45
47
  export declare function handleAPIRequest(request: Request, params: Record<string, any>, route: ServerRoute): Promise<Response>;
package/dist/requests.js CHANGED
@@ -62,8 +62,29 @@ export function getToken(request, sensitive = false) {
62
62
  return cookie.parse(request.headers.get('cookie') || '')[sensitive ? 'elevated_token' : 'session_token'];
63
63
  }
64
64
  }
65
- export async function createSessionData(userId, { elevated = false, noCookie } = {}) {
66
- const { token, expires } = await createSession(userId, elevated);
65
+ const uaParserExtension = {
66
+ browser: [[/(axium[- ]client)\/([\d.]+)/i], ['name', 'version']],
67
+ };
68
+ export async function getPrettyUA(request, uaOverride) {
69
+ const uaString = uaOverride || request.headers.get('User-Agent') || '';
70
+ try {
71
+ const { UAParser } = await import('ua-parser-js');
72
+ const ua = UAParser(uaString, uaParserExtension);
73
+ return [
74
+ ua.browser.name,
75
+ ua.browser.name && /axium[- ]client/i.test(ua.browser.name) ? ua.browser.version : ua.browser.major,
76
+ ua.os.name && ' on ' + ua.os.name,
77
+ ]
78
+ .filter(p => p)
79
+ .join(' ');
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ }
85
+ export async function createSessionData(userId, request, { elevated = false, noCookie, clientUA } = {}) {
86
+ const name = await getPrettyUA(request, clientUA);
87
+ const { token, expires } = await createSession(userId, name, elevated);
67
88
  const response = json({ userId, token: elevated ? '[[redacted:elevated]]' : token }, { status: 201 });
68
89
  if (noCookie)
69
90
  return response;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axium/server",
3
- "version": "0.37.1",
3
+ "version": "0.38.0",
4
4
  "author": "James Prevett <axium@jamespre.dev>",
5
5
  "funding": {
6
6
  "type": "individual",
@@ -47,8 +47,8 @@
47
47
  "clean": "rm -rf build .svelte-kit node_modules/{.vite,.vite-temp}"
48
48
  },
49
49
  "peerDependencies": {
50
- "@axium/client": ">=0.13.0",
51
- "@axium/core": ">=0.20.0",
50
+ "@axium/client": ">=0.17.0",
51
+ "@axium/core": ">=0.21.0",
52
52
  "kysely": "^0.28.0",
53
53
  "utilium": "^2.6.0",
54
54
  "zod": "^4.0.5"
@@ -66,5 +66,8 @@
66
66
  "devDependencies": {
67
67
  "@sveltejs/adapter-node": "^5.2.12",
68
68
  "vite-plugin-mkcert": "^1.17.8"
69
+ },
70
+ "optionalDependencies": {
71
+ "ua-parser-js": "^2.0.9"
69
72
  }
70
73
  }
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
+ import { fetchAPI, text } from '@axium/client';
2
3
  import { ClipboardCopy, FormDialog, Icon, Logout, SessionList, ZodForm } from '@axium/client/components';
3
- import { fetchAPI } from '@axium/client/requests';
4
4
  import '@axium/client/styles/account';
5
5
  import { createPasskey, deletePasskey, deleteUser, sendVerificationEmail, updatePasskey, updateUser } from '@axium/client/user';
6
6
  import { preferenceLabels, Preferences } from '@axium/core/preferences';
@@ -23,7 +23,7 @@
23
23
  </script>
24
24
 
25
25
  <svelte:head>
26
- <title>Your Account</title>
26
+ <title>{text('page.account.title')}</title>
27
27
  </svelte:head>
28
28
 
29
29
  {#snippet action(name: string, i: string = 'pen')}
@@ -34,95 +34,101 @@
34
34
 
35
35
  <div class="Account flex-content">
36
36
  <div id="pfp-container">
37
- <img id="pfp" src={getUserImage(user)} alt="User profile" width="100px" height="100px" />
37
+ <img id="pfp" src={getUserImage(user)} alt={text('page.account.profile_alt')} width="100px" height="100px" />
38
38
  </div>
39
- <p class="greeting">Welcome, {user.name}</p>
39
+ <p class="greeting">{text('page.account.greeting', { name: user.name })}</p>
40
40
 
41
41
  <div id="info" class="section main">
42
- <h3>Personal Information</h3>
42
+ <h3>{text('page.account.personal_info')}</h3>
43
43
  <div class="item info">
44
- <p class="subtle">Name</p>
44
+ <p class="subtle">{text('generic.username')}</p>
45
45
  <p>{user.name}</p>
46
46
  {@render action('edit_name')}
47
47
  </div>
48
- <FormDialog id="edit_name" submit={_editUser} submitText="Change">
48
+ <FormDialog id="edit_name" submit={_editUser} submitText={text('generic.change')}>
49
49
  <div>
50
- <label for="name">What do you want to be called?</label>
50
+ <label for="name">{text('page.account.edit_name')}</label>
51
51
  <input name="name" type="text" value={user.name || ''} required />
52
52
  </div>
53
53
  </FormDialog>
54
54
  <div class="item info">
55
- <p class="subtle">Email</p>
55
+ <p class="subtle">{text('generic.email')}</p>
56
56
  <p>
57
57
  {user.email}
58
58
  {#if user.emailVerified}
59
- <dfn title="Email verified on {user.emailVerified.toLocaleDateString()}">
59
+ <dfn title={text('page.account.email_verified_on', { date: user.emailVerified.toLocaleDateString() })}>
60
60
  <Icon i="regular/circle-check" />
61
61
  </dfn>
62
62
  {:else if canVerify}
63
63
  <button onclick={() => sendVerificationEmail(user.id).then(() => (verificationSent = true))}>
64
- {verificationSent ? 'Verification email sent' : 'Verify'}
64
+ {verificationSent ? text('page.account.verification_sent') : text('page.account.verify')}
65
65
  </button>
66
66
  {/if}
67
67
  </p>
68
68
  {@render action('edit_email')}
69
69
  </div>
70
- <FormDialog id="edit_email" submit={_editUser} submitText="Change">
70
+ <FormDialog id="edit_email" submit={_editUser} submitText={text('generic.change')}>
71
71
  <div>
72
- <label for="email">Email Address</label>
72
+ <label for="email">{text('page.account.edit_email')}</label>
73
73
  <input name="email" type="email" value={user.email || ''} required />
74
74
  </div>
75
75
  </FormDialog>
76
76
 
77
77
  <div class="item info">
78
- <p class="subtle">User ID <dfn title="This is your UUID. It can't be changed."><Icon i="regular/circle-info" /></dfn></p>
78
+ <p class="subtle">
79
+ {text('page.account.user_id')} <dfn title={text('page.account.user_id_hint')}><Icon i="regular/circle-info" /></dfn>
80
+ </p>
79
81
  <p>{user.id}</p>
80
82
  <ClipboardCopy value={user.id} --size="16px" />
81
83
  </div>
82
84
  <div class="inline-button-container">
83
- <button command="show-modal" commandfor="logout" class="inline-button signout">Sign Out</button>
84
- <button command="show-modal" commandfor="delete" class="inline-button danger">Delete Account</button>
85
+ <button command="show-modal" commandfor="logout" class="inline-button logout">{text('generic.logout')}</button>
86
+ <button command="show-modal" commandfor="delete" class="inline-button danger">{text('page.account.delete_account')}</button>
85
87
  <Logout />
86
88
  <FormDialog
87
89
  id="delete"
88
90
  submit={() => deleteUser(user.id).then(() => (window.location.href = '/'))}
89
- submitText="Delete Account"
91
+ submitText={text('page.account.delete_account')}
90
92
  submitDanger
91
93
  >
92
- <p>Are you sure you want to delete your account?<br />This action can't be undone.</p>
94
+ <p>{text('page.account.delete_account_confirm')}<br />{text('generic.action_irreversible')}</p>
93
95
  </FormDialog>
94
96
  </div>
95
97
  </div>
96
98
 
97
99
  <div id="passkeys" class="section main">
98
- <h3>Passkeys</h3>
100
+ <h3>{text('page.account.passkeys.title')}</h3>
99
101
  {#each passkeys as passkey}
100
102
  <div class="item passkey">
101
103
  <p>
102
- <dfn title={passkey.deviceType == 'multiDevice' ? 'Multiple devices' : 'Single device'}>
104
+ <dfn
105
+ title={passkey.deviceType == 'multiDevice'
106
+ ? text('page.account.passkeys.multi_device')
107
+ : text('page.account.passkeys.single_device')}
108
+ >
103
109
  <Icon i={passkey.deviceType == 'multiDevice' ? 'laptop-mobile' : 'mobile'} --size="16px" />
104
110
  </dfn>
105
- <dfn title="This passkey is {passkey.backedUp ? '' : 'not '}backed up">
111
+ <dfn title={passkey.backedUp ? text('page.account.passkeys.backed_up') : text('page.account.passkeys.not_backed_up')}>
106
112
  <Icon i={passkey.backedUp ? 'circle-check' : 'circle-xmark'} --size="16px" />
107
113
  </dfn>
108
114
  {#if passkey.name}
109
115
  <p>{passkey.name}</p>
110
116
  {:else}
111
- <p class="subtle"><i>Unnamed</i></p>
117
+ <p class="subtle"><i>{text('generic.unnamed')}</i></p>
112
118
  {/if}
113
119
  </p>
114
- <p>Created {passkey.createdAt.toLocaleString()}</p>
120
+ <p>{text('page.account.passkeys.created', { date: passkey.createdAt.toLocaleString() })}</p>
115
121
  <button commandfor="edit_passkey:{passkey.id}" command="show-modal" class="icon-text">
116
122
  <Icon i="pen" --size="16px" />
117
- <span class="mobile-only">Rename</span>
123
+ <span class="mobile-only">{text('page.account.passkeys.rename')}</span>
118
124
  </button>
119
125
  {#if passkeys.length > 1}
120
126
  <button commandfor="delete_passkey:{passkey.id}" command="show-modal" class="icon-text">
121
127
  <Icon i="trash" --size="16px" />
122
- <span class="mobile-only">Delete</span>
128
+ <span class="mobile-only">{text('page.account.passkeys.delete')}</span>
123
129
  </button>
124
130
  {:else}
125
- <dfn title="You must have at least one passkey" class="disabled icon-text mobile-hide">
131
+ <dfn title={text('page.account.passkeys.min_one')} class="disabled icon-text mobile-hide">
126
132
  <Icon i="trash-slash" --fill="#888" --size="16px" />
127
133
  </dfn>
128
134
  {/if}
@@ -130,39 +136,40 @@
130
136
  <FormDialog
131
137
  id={'edit_passkey:' + passkey.id}
132
138
  submit={data => {
133
- if (typeof data.name != 'string') throw 'Passkey name must be a string';
139
+ if (typeof data.name != 'string') throw text('page.account.passkeys.name_type_error');
134
140
  passkey.name = data.name;
135
141
  return updatePasskey(passkey.id, data);
136
142
  }}
137
- submitText="Change"
143
+ submitText={text('generic.change')}
138
144
  >
139
145
  <div>
140
- <label for="name">Passkey Name</label>
146
+ <label for="name">{text('page.account.passkeys.edit_name')}</label>
141
147
  <input name="name" type="text" value={passkey.name || ''} />
142
148
  </div>
143
149
  </FormDialog>
144
150
  <FormDialog
145
151
  id={'delete_passkey:' + passkey.id}
146
152
  submit={() => deletePasskey(passkey.id).then(() => passkeys.splice(passkeys.indexOf(passkey), 1))}
147
- submitText="Delete"
153
+ submitText={text('page.account.passkeys.delete')}
148
154
  submitDanger={true}
149
155
  >
150
- <p>Are you sure you want to delete this passkey?<br />This action can't be undone.</p>
156
+ <p>{text('page.account.passkeys.delete_confirm')}<br />{text('generic.action_irreversible')}</p>
151
157
  </FormDialog>
152
158
  {/each}
153
159
 
154
160
  <button onclick={() => createPasskey(user.id).then(passkeys.push.bind(passkeys))} class="inline-button icon-text">
155
- <Icon i="plus" /> Create
161
+ <Icon i="plus" />
162
+ {text('page.account.passkeys.create')}
156
163
  </button>
157
164
  </div>
158
165
 
159
166
  <div id="sessions" class="section main">
160
- <h3>Sessions</h3>
167
+ <h3>{text('page.account.sessions')}</h3>
161
168
  <SessionList {sessions} {currentSession} {user} redirectAfterLogoutAll />
162
169
  </div>
163
170
 
164
171
  <div id="preferences" class="section main">
165
- <h3>Preferences</h3>
172
+ <h3>{text('page.account.preferences')}</h3>
166
173
  <ZodForm
167
174
  bind:rootValue={user.preferences}
168
175
  idPrefix="preferences"