@axium/server 0.8.0 → 0.10.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.
Files changed (72) hide show
  1. package/{web/lib → assets}/styles.css +7 -1
  2. package/{web/api/index.ts → dist/api/index.d.ts} +0 -2
  3. package/dist/api/index.js +5 -0
  4. package/dist/api/metadata.d.ts +1 -0
  5. package/dist/api/metadata.js +28 -0
  6. package/dist/api/passkeys.d.ts +1 -0
  7. package/dist/api/passkeys.js +50 -0
  8. package/dist/api/register.d.ts +1 -0
  9. package/dist/api/register.js +70 -0
  10. package/dist/api/session.d.ts +1 -0
  11. package/dist/api/session.js +31 -0
  12. package/dist/api/users.d.ts +1 -0
  13. package/dist/api/users.js +244 -0
  14. package/dist/apps.d.ts +0 -5
  15. package/dist/apps.js +2 -9
  16. package/dist/auth.d.ts +9 -31
  17. package/dist/auth.js +20 -34
  18. package/dist/cli.js +200 -54
  19. package/dist/config.d.ts +52 -480
  20. package/dist/config.js +89 -56
  21. package/dist/database.d.ts +3 -3
  22. package/dist/database.js +57 -24
  23. package/dist/io.d.ts +6 -4
  24. package/dist/io.js +26 -19
  25. package/dist/plugins.d.ts +5 -2
  26. package/dist/plugins.js +16 -14
  27. package/dist/requests.d.ts +11 -0
  28. package/dist/requests.js +58 -0
  29. package/dist/routes.d.ts +12 -13
  30. package/dist/routes.js +21 -22
  31. package/dist/serve.d.ts +7 -0
  32. package/dist/serve.js +11 -0
  33. package/dist/state.d.ts +4 -0
  34. package/dist/state.js +22 -0
  35. package/dist/sveltekit.d.ts +8 -0
  36. package/dist/sveltekit.js +90 -0
  37. package/package.json +18 -10
  38. package/{web/routes → routes}/account/+page.svelte +115 -48
  39. package/svelte.config.js +36 -0
  40. package/web/hooks.server.ts +15 -4
  41. package/web/lib/ClipboardCopy.svelte +42 -0
  42. package/web/lib/Dialog.svelte +0 -1
  43. package/web/lib/FormDialog.svelte +9 -2
  44. package/web/lib/icons/Icon.svelte +3 -12
  45. package/web/template.html +18 -0
  46. package/web/tsconfig.json +3 -2
  47. package/web/api/metadata.ts +0 -35
  48. package/web/api/passkeys.ts +0 -56
  49. package/web/api/readme.md +0 -1
  50. package/web/api/register.ts +0 -83
  51. package/web/api/schemas.ts +0 -22
  52. package/web/api/session.ts +0 -33
  53. package/web/api/users.ts +0 -340
  54. package/web/api/utils.ts +0 -66
  55. package/web/app.html +0 -14
  56. package/web/auth.ts +0 -8
  57. package/web/index.server.ts +0 -1
  58. package/web/index.ts +0 -1
  59. package/web/lib/auth.ts +0 -12
  60. package/web/lib/index.ts +0 -5
  61. package/web/routes/+layout.svelte +0 -6
  62. package/web/routes/[...path]/+page.server.ts +0 -13
  63. package/web/routes/[appId]/[...page]/+page.server.ts +0 -14
  64. package/web/routes/api/[...path]/+server.ts +0 -49
  65. package/web/utils.ts +0 -26
  66. /package/{web/lib → assets}/icons/light.svg +0 -0
  67. /package/{web/lib → assets}/icons/regular.svg +0 -0
  68. /package/{web/lib → assets}/icons/solid.svg +0 -0
  69. /package/{web/routes → routes}/_axium/default/+page.svelte +0 -0
  70. /package/{web/routes → routes}/login/+page.svelte +0 -0
  71. /package/{web/routes → routes}/logout/+page.svelte +0 -0
  72. /package/{web/routes → routes}/register/+page.svelte +0 -0
package/dist/routes.d.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import type { RequestMethod } from '@axium/core/requests';
2
- import type { LoadEvent, RequestEvent } from '@sveltejs/kit';
2
+ import type { RequestEvent } from '@sveltejs/kit';
3
3
  import type { Component } from 'svelte';
4
4
  import type z from 'zod/v4';
5
5
  type _Params = Partial<Record<string, string>>;
6
- export type EndpointHandlers<Params extends _Params = _Params> = Partial<Record<RequestMethod, (event: RequestEvent<Params>) => object | Promise<object>>>;
6
+ type MaybePromise<T> = T | Promise<T>;
7
+ export type EndpointHandlers<Params extends _Params = _Params> = Partial<Record<RequestMethod, (event: RequestEvent<Params>) => MaybePromise<object | Response>>>;
7
8
  export type RouteParamOptions = z.ZodType;
8
9
  export interface CommonRouteOptions<Params extends _Params = _Params> {
9
10
  path: string;
@@ -15,6 +16,7 @@ export interface CommonRouteOptions<Params extends _Params = _Params> {
15
16
  * A route with server-side handlers for different HTTP methods.
16
17
  */
17
18
  export interface ServerRouteOptions<Params extends _Params = _Params> extends CommonRouteOptions<Params>, EndpointHandlers<Params> {
19
+ api?: boolean;
18
20
  }
19
21
  export interface WebRouteOptions extends CommonRouteOptions {
20
22
  load?(event: RequestEvent): object | Promise<object>;
@@ -25,31 +27,28 @@ export type RouteOptions = ServerRouteOptions | WebRouteOptions;
25
27
  export interface RouteCommon {
26
28
  path: string;
27
29
  params?: Record<string, RouteParamOptions>;
28
- [kBuiltin]: boolean;
29
30
  }
30
31
  export interface ServerRoute extends RouteCommon, EndpointHandlers {
32
+ api: boolean;
31
33
  server: true;
32
34
  }
33
35
  export interface WebRoute extends RouteCommon {
34
36
  server: false;
35
- load?(event: LoadEvent): object | Promise<object>;
36
- page?: Component;
37
+ load?(event: RequestEvent): object | Promise<object>;
38
+ page: Component;
37
39
  }
38
40
  export type Route = ServerRoute | WebRoute;
39
41
  /**
40
42
  * @internal
41
43
  */
42
44
  export declare const routes: Map<string, Route>;
43
- declare const kBuiltin: unique symbol;
44
- export declare function addRoute(opt: RouteOptions, _routeMap?: Map<string, Route>): void;
45
+ export declare function addRoute(opt: RouteOptions): void;
45
46
  /**
46
47
  * Resolve a request URL into a route.
47
48
  * This handles parsing of parameters in the URL.
48
49
  */
49
- export declare function resolveRoute<T extends Route>(event: RequestEvent | LoadEvent, _routeMap?: Map<string, T>): T | undefined;
50
- /**
51
- * This function marks all existing routes as built-in.
52
- * @internal
53
- */
54
- export declare function _markDefaults(): void;
50
+ export declare function resolveRoute(event: {
51
+ url: URL;
52
+ params?: object;
53
+ }): Route | undefined;
55
54
  export {};
package/dist/routes.js CHANGED
@@ -1,39 +1,47 @@
1
+ import { apps } from './apps.js';
2
+ import config from './config.js';
3
+ import { output } from './io.js';
4
+ import { _unique } from './state.js';
1
5
  /**
2
6
  * @internal
3
7
  */
4
- export const routes = new Map();
5
- const kBuiltin = Symbol('kBuiltin');
6
- export function addRoute(opt, _routeMap = routes) {
7
- const route = { ...opt, server: !('page' in opt), [kBuiltin]: false };
8
+ export const routes = _unique('routes', new Map());
9
+ export function addRoute(opt) {
10
+ const route = { ...opt, server: !('page' in opt) };
8
11
  if (!route.path.startsWith('/')) {
9
12
  throw new Error(`Route path must start with a slash: ${route.path}`);
10
13
  }
11
- if (route.path.startsWith('/api/') && !route.server) {
14
+ if (route.path.startsWith('/api/'))
15
+ route.api = true;
16
+ if (route.api && !route.server)
12
17
  throw new Error(`API routes cannot have a client page: ${route.path}`);
13
- }
14
- _routeMap.set(route.path, route);
18
+ routes.set(route.path, route);
19
+ output.debug('Added route: ' + route.path);
15
20
  }
16
21
  /**
17
22
  * Resolve a request URL into a route.
18
23
  * This handles parsing of parameters in the URL.
19
24
  */
20
- export function resolveRoute(event, _routeMap = routes) {
25
+ export function resolveRoute(event) {
21
26
  const { pathname } = event.url;
22
- if (_routeMap.has(pathname) && !pathname.split('/').some(p => p.startsWith(':')))
23
- return _routeMap.get(pathname);
27
+ if (routes.has(pathname) && !pathname.split('/').some(p => p.startsWith(':')))
28
+ return routes.get(pathname);
24
29
  // Otherwise we must have a parameterized route
25
- routes: for (const route of _routeMap.values()) {
30
+ _routes: for (const route of routes.values()) {
26
31
  const params = {};
27
32
  // Split the path and route into parts, zipped together
28
33
  const pathParts = pathname.split('/').filter(Boolean);
34
+ // Skips routes in disabled apps
35
+ if (apps.has(pathParts[0]) && config.apps.disabled.includes(pathParts[0]))
36
+ continue;
29
37
  for (const routePart of route.path.split('/').filter(Boolean)) {
30
38
  const pathPart = pathParts.shift();
31
39
  if (!pathPart)
32
- continue routes;
40
+ continue _routes;
33
41
  if (pathPart == routePart)
34
42
  continue;
35
43
  if (!routePart.startsWith(':'))
36
- continue routes;
44
+ continue _routes;
37
45
  params[routePart.slice(1)] = pathPart;
38
46
  }
39
47
  // we didn't find a match, since an exact match would have been found already
@@ -43,12 +51,3 @@ export function resolveRoute(event, _routeMap = routes) {
43
51
  return route;
44
52
  }
45
53
  }
46
- /**
47
- * This function marks all existing routes as built-in.
48
- * @internal
49
- */
50
- export function _markDefaults() {
51
- for (const route of routes.values()) {
52
- route[kBuiltin] = true;
53
- }
54
- }
@@ -0,0 +1,7 @@
1
+ import type { Server } from 'node:http';
2
+ export interface ServeOptions {
3
+ secure: boolean;
4
+ ssl_key: string;
5
+ ssl_cert: string;
6
+ }
7
+ export declare function serve(opt: Partial<ServeOptions>): Promise<Server>;
package/dist/serve.js ADDED
@@ -0,0 +1,11 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { createServer } from 'node:http';
3
+ import { createServer as createSecureServer } from 'node:https';
4
+ import config from './config.js';
5
+ const _handlerPath = '../build/handler.js';
6
+ export async function serve(opt) {
7
+ const { handler } = await import(_handlerPath);
8
+ if (!opt.secure && !config.web.secure)
9
+ return createServer(handler);
10
+ return createSecureServer({ key: readFileSync(opt.ssl_key || config.web.ssl_key), cert: readFileSync(opt.ssl_cert || config.web.ssl_cert) }, handler);
11
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Prevent duplicate shared state.
3
+ */
4
+ export declare function _unique<T>(id: string, value: T): T;
package/dist/state.js ADDED
@@ -0,0 +1,22 @@
1
+ import { styleText } from 'node:util';
2
+ const sym = Symbol.for('Axium:state');
3
+ globalThis[sym] ||= Object.create({ _errored: false });
4
+ /**
5
+ * Prevent duplicate shared state.
6
+ */
7
+ export function _unique(id, value) {
8
+ const state = globalThis[sym];
9
+ const _err = new Error();
10
+ Error.captureStackTrace(_err, _unique);
11
+ const stack = _err.stack.slice(6);
12
+ if (!(id in state)) {
13
+ state[id] = { value, stack };
14
+ return value;
15
+ }
16
+ if (!state._errored) {
17
+ console.error(styleText('red', 'Duplicate Axium server state! You might have multiple instances of the same module loaded.'));
18
+ state._errored = true;
19
+ }
20
+ console.warn(styleText('yellow', `Mitigating duplicate state! (${id})\n${stack}\nFrom original\n${state[id].stack}`));
21
+ return state[id].value;
22
+ }
@@ -0,0 +1,8 @@
1
+ import type { RequestEvent, ResolveOptions } from '@sveltejs/kit';
2
+ /**
3
+ * @internal
4
+ */
5
+ export declare function handle({ event, resolve, }: {
6
+ event: RequestEvent;
7
+ resolve: (event: RequestEvent, opts?: ResolveOptions) => Promise<Response>;
8
+ }): Promise<Response>;
@@ -0,0 +1,90 @@
1
+ import { error, json, redirect } from '@sveltejs/kit';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path/posix';
4
+ import { render } from 'svelte/server';
5
+ import z from 'zod/v4';
6
+ import { config } from './config.js';
7
+ import { resolveRoute } from './routes.js';
8
+ async function handleAPIRequest(event, route) {
9
+ const method = event.request.method;
10
+ const _warnings = [];
11
+ if (route.api && !event.request.headers.get('Accept')?.includes('application/json')) {
12
+ _warnings.push('Only application/json is supported');
13
+ event.request.headers.set('Accept', 'application/json');
14
+ }
15
+ for (const [key, type] of Object.entries(route.params || {})) {
16
+ if (!type)
17
+ continue;
18
+ try {
19
+ event.params[key] = type.parse(event.params[key]);
20
+ }
21
+ catch (e) {
22
+ error(400, `Invalid parameter: ${z.prettifyError(e)}`);
23
+ }
24
+ }
25
+ if (typeof route[method] != 'function')
26
+ error(405, `Method ${method} not allowed for ${route.path}`);
27
+ const result = await route[method](event);
28
+ if (result instanceof Response)
29
+ return result;
30
+ result._warnings ||= [];
31
+ result._warnings.push(..._warnings);
32
+ return json(result);
33
+ }
34
+ function handleError(e) {
35
+ if ('body' in e)
36
+ return json(e.body, { status: e.status });
37
+ console.error(e);
38
+ return json({ message: 'Internal Error' + (config.debug ? ': ' + e.message : '') }, { status: 500 });
39
+ }
40
+ const templatePath = join(import.meta.dirname, '../web/template.html');
41
+ const template = readFileSync(templatePath, 'utf-8');
42
+ function fillTemplate({ head, body }, env = {}, nonce = '') {
43
+ return (template
44
+ .replace('%sveltekit.head%', head)
45
+ .replace('%sveltekit.body%', body)
46
+ .replace(/%sveltekit\.assets%/g, config.web.assets)
47
+ // Unused for now.
48
+ .replace(/%sveltekit\.nonce%/g, nonce)
49
+ .replace(/%sveltekit\.env\.([^%]+)%/g, (_match, capture) => env[capture] ?? ''));
50
+ }
51
+ /**
52
+ * @internal
53
+ */
54
+ export async function handle({ event, resolve, }) {
55
+ const route = resolveRoute(event);
56
+ if (!route && event.url.pathname === '/')
57
+ redirect(303, '/_axium/default');
58
+ if (config.debug)
59
+ console.log(event.request.method.padEnd(7), route ? route.path : event.url.pathname);
60
+ if (!route)
61
+ return await resolve(event).catch(handleError);
62
+ if (route.server == true) {
63
+ if (route.api)
64
+ return await handleAPIRequest(event, route).catch(handleError);
65
+ const run = route[event.request.method];
66
+ if (typeof run !== 'function') {
67
+ error(405, `Method ${event.request.method} not allowed for ${route.path}`);
68
+ }
69
+ try {
70
+ const result = await run(event);
71
+ if (result instanceof Response)
72
+ return result;
73
+ return json(result);
74
+ }
75
+ catch (e) {
76
+ return handleError(e);
77
+ }
78
+ }
79
+ const data = await route.load?.(event);
80
+ const body = fillTemplate(render(route.page));
81
+ return new Response(body, {
82
+ headers: {
83
+ 'Content-Type': 'text/html; charset=utf-8',
84
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
85
+ Pragma: 'no-cache',
86
+ Expires: '0',
87
+ },
88
+ status: 200,
89
+ });
90
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axium/server",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "author": "James Prevett <axium@jamespre.dev> (https://jamespre.dev)",
5
5
  "funding": {
6
6
  "type": "individual",
@@ -19,13 +19,17 @@
19
19
  "types": "dist/index.d.ts",
20
20
  "exports": {
21
21
  ".": "./dist/index.js",
22
- "./*": "./dist/*",
23
- "./web": "./web/index.js",
24
- "./web/*": "./web/*"
22
+ "./*": "./dist/*.js",
23
+ "./web/*": "./web/*",
24
+ "./$routes": "./routes",
25
+ "./svelte.config.js": "./svelte.config.js"
25
26
  },
26
27
  "files": [
28
+ "assets",
27
29
  "dist",
28
- "web"
30
+ "routes",
31
+ "web",
32
+ "svelte.config.js"
29
33
  ],
30
34
  "bin": {
31
35
  "axium": "dist/cli.js"
@@ -33,18 +37,22 @@
33
37
  "scripts": {
34
38
  "build": "tsc"
35
39
  },
40
+ "peerDependencies": {
41
+ "@axium/client": ">=0.0.2",
42
+ "@axium/core": ">=0.3.0",
43
+ "utilium": "^2.3.8",
44
+ "zod": "^3.25.61"
45
+ },
36
46
  "dependencies": {
47
+ "@axium/server": "file:.",
37
48
  "@simplewebauthn/server": "^13.1.1",
38
49
  "@sveltejs/kit": "^2.20.2",
39
50
  "@types/pg": "^8.11.11",
40
51
  "commander": "^13.1.0",
41
- "kysely": "^0.27.5",
52
+ "kysely": "^0.28.0",
42
53
  "logzen": "^0.7.0",
43
54
  "mime": "^4.0.7",
44
- "pg": "^8.14.1",
45
- "utilium": "^2.3.8",
46
- "zod": "^3.25.61",
47
- "@axium/core": ">=0.2.0"
55
+ "pg": "^8.14.1"
48
56
  },
49
57
  "devDependencies": {
50
58
  "@sveltejs/adapter-node": "^5.2.12",
@@ -1,32 +1,47 @@
1
1
  <script lang="ts">
2
+ import { goto } from '$app/navigation';
3
+ import ClipboardCopy from '$lib/ClipboardCopy.svelte';
2
4
  import FormDialog from '$lib/FormDialog.svelte';
3
5
  import Icon from '$lib/icons/Icon.svelte';
4
6
  import {
5
- currentSession,
7
+ createPasskey,
6
8
  deletePasskey,
9
+ deleteUser,
10
+ emailVerificationEnabled,
11
+ getCurrentSession,
7
12
  getPasskeys,
13
+ getSessions,
14
+ logout,
15
+ logoutAll,
8
16
  sendVerificationEmail,
9
17
  updatePasskey,
10
18
  updateUser,
11
- createPasskey,
12
- deleteUser,
13
19
  } from '@axium/client/user';
14
- import type { Passkey } from '@axium/core/api';
20
+ import type { Passkey, Session } from '@axium/core/api';
15
21
  import { getUserImage, type User } from '@axium/core/user';
16
22
 
17
23
  const dialogs = $state<Record<string, HTMLDialogElement>>({});
18
24
 
19
25
  let verificationSent = $state(false);
26
+ let currentSession = $state<Session & { user: User }>();
20
27
  let user = $state<User>();
28
+ let canVerify = $state(false);
29
+ let passkeys = $state<Passkey[]>([]);
30
+ let sessions = $state<Session[]>([]);
21
31
 
22
32
  async function ready() {
23
- const session = await currentSession();
24
- user = session.user;
33
+ currentSession = await getCurrentSession().catch(() => {
34
+ goto('/login?after=/account');
35
+ return null;
36
+ })!;
37
+ user = currentSession.user;
25
38
 
26
39
  passkeys = await getPasskeys(user.id);
27
- }
28
40
 
29
- let passkeys = $state<Passkey[]>([]);
41
+ sessions = await getSessions(user.id);
42
+
43
+ canVerify = await emailVerificationEnabled(user.id);
44
+ }
30
45
 
31
46
  async function _editUser(data) {
32
47
  const result = await updateUser(user.id, data);
@@ -39,14 +54,8 @@
39
54
  </svelte:head>
40
55
 
41
56
  {#snippet action(name: string, i: string = 'pen')}
42
- <button
43
- style:display="contents"
44
- style:cursor="pointer"
45
- onclick={() => {
46
- dialogs[name].showModal();
47
- }}
48
- >
49
- <Icon {i} />
57
+ <button style:display="contents" onclick={() => dialogs[name].showModal()}>
58
+ <Icon {i} --size="16px" />
50
59
  </button>
51
60
  {/snippet}
52
61
 
@@ -56,9 +65,10 @@
56
65
  <p class="greeting">Welcome, {user.name}</p>
57
66
 
58
67
  <div class="section main">
59
- <div class="item">
60
- <span class="subtle">Name</span>
61
- <span>{user.name}</span>
68
+ <h3>Personal Information</h3>
69
+ <div class="item info">
70
+ <p class="subtle">Name</p>
71
+ <p>{user.name}</p>
62
72
  {@render action('edit_name')}
63
73
  </div>
64
74
  <FormDialog bind:dialog={dialogs.edit_name} submit={_editUser} submitText="Change">
@@ -67,20 +77,20 @@
67
77
  <input name="name" type="text" value={user.name || ''} required />
68
78
  </div>
69
79
  </FormDialog>
70
- <div class="item">
71
- <span class="subtle">Email</span>
72
- <span>
80
+ <div class="item info">
81
+ <p class="subtle">Email</p>
82
+ <p>
73
83
  {user.email}
74
84
  {#if user.emailVerified}
75
- <dfn title="Email verified on {new Date(user.emailVerified).toLocaleDateString()}">
85
+ <dfn title="Email verified on {user.emailVerified.toLocaleDateString()}">
76
86
  <Icon i="regular/circle-check" />
77
87
  </dfn>
78
- {:else}
88
+ {:else if canVerify}
79
89
  <button onclick={() => sendVerificationEmail(user.id).then(() => (verificationSent = true))}>
80
90
  {verificationSent ? 'Verification email sent' : 'Verify'}
81
91
  </button>
82
92
  {/if}
83
- </span>
93
+ </p>
84
94
  {@render action('edit_email')}
85
95
  </div>
86
96
  <FormDialog bind:dialog={dialogs.edit_email} submit={_editUser} submitText="Change">
@@ -90,16 +100,20 @@
90
100
  </div>
91
101
  </FormDialog>
92
102
 
93
- <div class="item">
94
- <p class="subtle">User ID <dfn title="This is your UUID."><Icon i="regular/circle-info" /></dfn></p>
103
+ <div class="item info">
104
+ <p class="subtle">User ID <dfn title="This is your UUID. It can't be changed."><Icon i="regular/circle-info" /></dfn></p>
95
105
  <p>{user.id}</p>
106
+ <ClipboardCopy value={user.id} --size="16px" />
96
107
  </div>
97
108
  <span>
98
109
  <a class="signout" href="/logout"><button>Sign out</button></a>
99
- <button style:cursor="pointer" onclick={() => dialogs.delete.showModal()} style:width="fit-content" class="danger"
100
- >Delete Account</button
110
+ <button style:cursor="pointer" onclick={() => dialogs.delete.showModal()} class="danger">Delete Account</button>
111
+ <FormDialog
112
+ bind:dialog={dialogs.delete}
113
+ submit={() => deleteUser(user.id).then(() => goto('/'))}
114
+ submitText="Delete Account"
115
+ submitDanger
101
116
  >
102
- <FormDialog bind:dialog={dialogs.delete} submit={() => deleteUser(user.id)} submitText="Delete Account" submitDanger>
103
117
  <p>Are you sure you want to delete your account?<br />This action can't be undone.</p>
104
118
  </FormDialog>
105
119
  </span>
@@ -108,25 +122,25 @@
108
122
  <div class="section main">
109
123
  <h3>Passkeys</h3>
110
124
  {#each passkeys as passkey}
111
- <div class="passkey">
125
+ <div class="item passkey">
112
126
  <dfn title={passkey.deviceType == 'multiDevice' ? 'Multiple devices' : 'Single device'}>
113
- <Icon i={passkey.deviceType == 'multiDevice' ? 'laptop-mobile' : 'mobile'} />
127
+ <Icon i={passkey.deviceType == 'multiDevice' ? 'laptop-mobile' : 'mobile'} --size="16px" />
114
128
  </dfn>
115
129
  <dfn title="This passkey is {passkey.backedUp ? '' : 'not '}backed up">
116
- <Icon i={passkey.backedUp ? 'circle-check' : 'circle-xmark'} />
130
+ <Icon i={passkey.backedUp ? 'circle-check' : 'circle-xmark'} --size="16px" />
117
131
  </dfn>
118
- <p>Created {new Date(passkey.createdAt).toLocaleString()}</p>
119
132
  {#if passkey.name}
120
133
  <p>{passkey.name}</p>
121
134
  {:else}
122
135
  <p class="subtle"><i>Unnamed</i></p>
123
136
  {/if}
137
+ <p>Created {passkey.createdAt.toLocaleString()}</p>
124
138
  {@render action('edit_passkey#' + passkey.id)}
125
139
  {#if passkeys.length > 1}
126
140
  {@render action('delete_passkey#' + passkey.id, 'trash')}
127
141
  {:else}
128
142
  <dfn title="You must have at least one passkey" class="disabled">
129
- <Icon i="trash-slash" --fill="#888" />
143
+ <Icon i="trash-slash" --fill="#888" --size="16px" />
130
144
  </dfn>
131
145
  {/if}
132
146
  </div>
@@ -153,12 +167,56 @@
153
167
  <p>Are you sure you want to delete this passkey?<br />This action can't be undone.</p>
154
168
  </FormDialog>
155
169
  {/each}
156
- <button onclick={() => createPasskey(user.id).then(passkeys.push.bind(passkeys))}><Icon i="plus" /> Create</button>
170
+ <span>
171
+ <button onclick={() => createPasskey(user.id).then(passkeys.push.bind(passkeys))}><Icon i="plus" /> Create</button>
172
+ </span>
173
+ </div>
174
+
175
+ <div class="section main">
176
+ <h3>Sessions</h3>
177
+ {#each sessions as session}
178
+ <div class="item session">
179
+ <p>
180
+ {session.id.slice(0, 4)}...{session.id.slice(-4)}
181
+ {#if session.id == currentSession.id}
182
+ <span class="current">Current</span>
183
+ {/if}
184
+ {#if session.elevated}
185
+ <span class="elevated">Elevated</span>
186
+ {/if}
187
+ </p>
188
+ <p>Created {session.created.toLocaleString()}</p>
189
+ <p>Expires {session.expires.toLocaleString()}</p>
190
+ {@render action('logout#' + session.id, 'right-from-bracket')}
191
+ </div>
192
+ <FormDialog
193
+ bind:dialog={dialogs['logout#' + session.id]}
194
+ submit={() =>
195
+ logout(user.id, session.id).then(() => {
196
+ if (session.id == currentSession.id) goto('/');
197
+ else sessions.splice(sessions.indexOf(session), 1);
198
+ })}
199
+ submitText="Logout"
200
+ >
201
+ <p>Are you sure you want to log out this session?</p>
202
+ </FormDialog>
203
+ {/each}
204
+ <span>
205
+ <button onclick={() => dialogs.logout_all.showModal()} class="danger">Logout All</button>
206
+ </span>
207
+ <FormDialog
208
+ bind:dialog={dialogs['logout_all']}
209
+ submit={() => logoutAll(user.id).then(() => goto('/'))}
210
+ submitText="Logout All Sessions"
211
+ submitDanger
212
+ >
213
+ <p>Are you sure you want to log out all sessions?</p>
214
+ </FormDialog>
157
215
  </div>
158
216
  </div>
159
217
  {:catch error}
160
218
  <div class="error">
161
- <h3>Failed to load your account</h3>
219
+ <h3>Failed to load account</h3>
162
220
  <p>{'message' in error ? error.message : error}</p>
163
221
  </div>
164
222
  {/await}
@@ -191,12 +249,16 @@
191
249
 
192
250
  .section .item {
193
251
  display: grid;
194
- grid-template-columns: 10em 1fr 2em;
195
252
  align-items: center;
196
253
  width: 100%;
197
254
  gap: 1em;
198
255
  text-wrap: nowrap;
256
+ border-top: 1px solid #8888;
199
257
  padding-bottom: 1em;
258
+ }
259
+
260
+ .info {
261
+ grid-template-columns: 10em 1fr 2em;
200
262
 
201
263
  > :first-child {
202
264
  margin-left: 1em;
@@ -204,21 +266,26 @@
204
266
  }
205
267
 
206
268
  .passkey {
207
- display: grid;
208
269
  grid-template-columns: 1em 1em 1fr 1fr 1em 1em;
209
- border-top: 1px solid #8888;
210
- align-items: center;
211
- width: 100%;
212
- gap: 1em;
213
- text-wrap: nowrap;
214
- padding-bottom: 1em;
215
270
 
216
- dfn {
271
+ dfn:not(.disabled) {
217
272
  cursor: help;
218
273
  }
274
+ }
275
+
276
+ .session {
277
+ grid-template-columns: 1fr 1fr 1fr 1em;
278
+
279
+ .current {
280
+ border-radius: 2em;
281
+ padding: 0 0.5em;
282
+ background-color: #337;
283
+ }
219
284
 
220
- dfn.disabled {
221
- cursor: not-allowed;
285
+ .elevated {
286
+ border-radius: 2em;
287
+ padding: 0 0.5em;
288
+ background-color: #733;
222
289
  }
223
290
  }
224
291
  </style>
@@ -0,0 +1,36 @@
1
+ import node from '@sveltejs/adapter-node';
2
+ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
3
+ import { join } from 'node:path/posix';
4
+
5
+ /**
6
+ * Paths relative to the directory of this file.
7
+ * This allows this file to be imported from other projects and still resolve to the correct paths.
8
+ */
9
+ const fixed = p => join(import.meta.dirname, p);
10
+
11
+ /** @type {import('@sveltejs/kit').Config} */
12
+ export default {
13
+ compilerOptions: {
14
+ runes: true,
15
+ },
16
+ preprocess: vitePreprocess({ script: true }),
17
+ vitePlugin: {
18
+ exclude: '@axium/server/**',
19
+ },
20
+ kit: {
21
+ adapter: node(),
22
+ alias: {
23
+ $stores: fixed('web/stores'),
24
+ $lib: fixed('web/lib'),
25
+ },
26
+ files: {
27
+ routes: 'routes',
28
+ lib: fixed('web/lib'),
29
+ assets: fixed('assets'),
30
+ appTemplate: fixed('web/template.html'),
31
+ hooks: {
32
+ server: fixed('web/hooks.server.ts'),
33
+ },
34
+ },
35
+ },
36
+ };