@axium/client 0.4.9 → 0.6.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/assets/styles.css CHANGED
@@ -65,6 +65,27 @@ textarea {
65
65
  outline: none;
66
66
  }
67
67
 
68
+ select,
69
+ ::picker(select) {
70
+ appearance: base-select;
71
+ }
72
+
73
+ ::picker(select) {
74
+ border: 1px solid var(--border-accent);
75
+ padding: 1em;
76
+ border-radius: 0.5em;
77
+ background-color: var(--bg-menu);
78
+ }
79
+
80
+ option {
81
+ padding: 0.25em 0;
82
+ border-radius: 0.5em;
83
+ }
84
+
85
+ option:hover {
86
+ background-color: var(--bg-alt);
87
+ }
88
+
68
89
  button {
69
90
  cursor: pointer;
70
91
  }
@@ -73,6 +94,13 @@ button:hover {
73
94
  background-color: hsl(var(--hue) 15 calc(var(--bg-light) + (var(--light-step) * 2)));
74
95
  }
75
96
 
97
+ code,
98
+ pre {
99
+ background-color: var(--bg-menu);
100
+ padding: 1em;
101
+ border-radius: 0.5em;
102
+ }
103
+
76
104
  .error {
77
105
  padding: 1em;
78
106
  border-radius: 0.5em;
@@ -0,0 +1,22 @@
1
+ [Unit]
2
+ Description=Axium Client Daemon
3
+ Wants=network-online.target
4
+ After=network-online.target
5
+ RequiresMountsFor=%Y
6
+ StartLimitIntervalSec=1min
7
+ StartLimitBurst=5
8
+ StartLimitAction=none
9
+ FailureAction=none
10
+
11
+ [Service]
12
+ Type=simple
13
+ ExecStartPre=/usr/bin/env cd "%Y/../../.."
14
+ ExecStart=/usr/bin/env npx --prefix "%Y/../../.." axium-client run
15
+ ExecReload=kill -HUP $MAINPID
16
+ SyslogIdentifier=axium-client
17
+ Restart=on-failure
18
+ RestartSec=3s
19
+ TimeoutStopSec=15s
20
+
21
+ [Install]
22
+ WantedBy=default.target
@@ -0,0 +1,23 @@
1
+ export declare const configDir: string;
2
+ export declare function session(): {
3
+ id: string;
4
+ userId: string;
5
+ expires: Date;
6
+ created: Date;
7
+ elevated: boolean;
8
+ user: {
9
+ id: string;
10
+ name: string;
11
+ email: string;
12
+ preferences: Record<string, any>;
13
+ roles: string[];
14
+ registeredAt: Date;
15
+ isAdmin: boolean;
16
+ emailVerified?: Date | null | undefined;
17
+ image?: string | null | undefined;
18
+ };
19
+ };
20
+ export declare function loadConfig(safe: boolean): Promise<void>;
21
+ export declare function saveConfig(): void;
22
+ export declare const _dayMs: number;
23
+ export declare function updateCache(force: boolean): Promise<void>;
@@ -0,0 +1,52 @@
1
+ import * as io from '@axium/core/node/io';
2
+ import { loadPlugin } from '@axium/core/node/plugins';
3
+ import { mkdirSync, readFileSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path/posix';
6
+ import * as z from 'zod';
7
+ import { fetchAPI, setPrefix, setToken } from '../requests.js';
8
+ import { getCurrentSession } from '../user.js';
9
+ import { ClientConfig, config } from '../config.js';
10
+ export const configDir = join(homedir(), '.config/axium');
11
+ mkdirSync(configDir, { recursive: true });
12
+ const axcConfig = join(configDir, 'config.json');
13
+ export function session() {
14
+ if (!config.token)
15
+ io.exit('Not logged in.', 4);
16
+ if (!config.cache)
17
+ io.exit('No session data available.', 3);
18
+ return config.cache.session;
19
+ }
20
+ export async function loadConfig(safe) {
21
+ try {
22
+ Object.assign(config, ClientConfig.parse(JSON.parse(readFileSync(axcConfig, 'utf-8'))));
23
+ if (config.server)
24
+ setPrefix(config.server);
25
+ if (config.token)
26
+ setToken(config.token);
27
+ for (const plugin of config.plugins ?? [])
28
+ await loadPlugin('client', plugin, axcConfig, safe);
29
+ }
30
+ catch (e) {
31
+ io.warn('Failed to load config: ' + (e instanceof z.core.$ZodError ? z.prettifyError(e) : io._debugOutput ? e.stack : e.message));
32
+ }
33
+ }
34
+ export function saveConfig() {
35
+ io.writeJSON(axcConfig, config);
36
+ io.debug('Saved config to ' + axcConfig);
37
+ }
38
+ export const _dayMs = 24 * 3600_000;
39
+ export async function updateCache(force) {
40
+ if (!force && config.cache && config.cache.fetched + _dayMs > Date.now())
41
+ return;
42
+ io.start('Fetching metadata');
43
+ const [session, apps] = await Promise.all([getCurrentSession(), fetchAPI('GET', 'apps')]).catch(err => io.exit(err.message));
44
+ try {
45
+ config.cache = { fetched: Date.now(), session, apps };
46
+ saveConfig();
47
+ io.done();
48
+ }
49
+ catch {
50
+ return;
51
+ }
52
+ }
@@ -0,0 +1,2 @@
1
+ #! /usr/bin/env node
2
+ export {};
@@ -0,0 +1,244 @@
1
+ #! /usr/bin/env node
2
+ var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
3
+ if (value !== null && value !== void 0) {
4
+ if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
5
+ var dispose, inner;
6
+ if (async) {
7
+ if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
8
+ dispose = value[Symbol.asyncDispose];
9
+ }
10
+ if (dispose === void 0) {
11
+ if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
12
+ dispose = value[Symbol.dispose];
13
+ if (async) inner = dispose;
14
+ }
15
+ if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
16
+ if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
17
+ env.stack.push({ value: value, dispose: dispose, async: async });
18
+ }
19
+ else if (async) {
20
+ env.stack.push({ async: true });
21
+ }
22
+ return value;
23
+ };
24
+ var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
25
+ return function (env) {
26
+ function fail(e) {
27
+ env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
28
+ env.hasError = true;
29
+ }
30
+ var r, s = 0;
31
+ function next() {
32
+ while (r = env.stack.pop()) {
33
+ try {
34
+ if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
35
+ if (r.dispose) {
36
+ var result = r.dispose.call(r.value);
37
+ if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
38
+ }
39
+ else s |= 1;
40
+ }
41
+ catch (e) {
42
+ fail(e);
43
+ }
44
+ }
45
+ if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
46
+ if (env.hasError) throw env.error;
47
+ }
48
+ return next();
49
+ };
50
+ })(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
51
+ var e = new Error(message);
52
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
53
+ });
54
+ import { outputDaemonStatus, io, pluginText } from '@axium/core/node';
55
+ import { _findPlugin, plugins } from '@axium/core/plugins';
56
+ import { program } from 'commander';
57
+ import { createServer } from 'node:http';
58
+ import { createInterface } from 'node:readline/promises';
59
+ import { styleText } from 'node:util';
60
+ import * as z from 'zod';
61
+ import $pkg from '../../package.json' with { type: 'json' };
62
+ import { config, resolveServerURL } from '../config.js';
63
+ import { prefix, setPrefix, setToken } from '../requests.js';
64
+ import { getCurrentSession, logout } from '../user.js';
65
+ import { loadConfig, saveConfig, updateCache } from './config.js';
66
+ const safe = z.stringbool().default(false).parse(process.env.SAFE?.toLowerCase()) || process.argv.includes('--safe');
67
+ await loadConfig(safe);
68
+ process.on('SIGHUP', () => {
69
+ io.info('Reloading configuration due to SIGHUP.');
70
+ void loadConfig(safe);
71
+ });
72
+ var rl, axiumPlugin;
73
+ const env_1 = { stack: [], error: void 0, hasError: false };
74
+ try {
75
+ rl = __addDisposableResource(env_1, createInterface({
76
+ input: process.stdin,
77
+ output: process.stdout,
78
+ }), false);
79
+ rl.on('SIGINT', () => io.exit('Aborted.', 7));
80
+ program
81
+ .version($pkg.version)
82
+ .name('axium-client')
83
+ .alias('axc')
84
+ .description('Axium client CLI')
85
+ .configureHelp({ showGlobalOptions: true })
86
+ .option('--debug', 'override debug mode')
87
+ .option('--no-debug', 'override debug mode')
88
+ .option('--refresh-session', 'Force a refresh of session and user metadata from server')
89
+ .option('--cache-only', 'Run entirely from local cache, even if it is expired.')
90
+ .option('--safe', 'do not execute code from plugins');
91
+ program.on('option:debug', () => io._setDebugOutput(true));
92
+ program.hook('preAction', async (_, action) => {
93
+ const opt = action.optsWithGlobals();
94
+ if (!config.token)
95
+ return;
96
+ if (!opt.cacheOnly)
97
+ await updateCache(opt.refreshSession);
98
+ });
99
+ program
100
+ .command('login')
101
+ .description('Log in to your account on an Axium server')
102
+ .argument('[server]', 'Axium server URL')
103
+ .action(async (url) => {
104
+ if (prefix[0] != '/')
105
+ url ||= prefix;
106
+ url ||= await rl.question('Axium server URL: ');
107
+ url = resolveServerURL(url);
108
+ setPrefix(url);
109
+ const sessionReady = Promise.withResolvers();
110
+ // eslint-disable-next-line @typescript-eslint/no-misused-promises
111
+ const server = createServer(async (req, res) => {
112
+ res.setHeader('access-control-allow-origin', '*');
113
+ res.setHeader('access-control-allow-methods', '*');
114
+ res.setHeader('access-control-allow-headers', '*');
115
+ if (req.method == 'HEAD' || req.method == 'OPTIONS') {
116
+ res.writeHead(200).end();
117
+ return;
118
+ }
119
+ if (!req.headers['content-type']?.endsWith('/json')) {
120
+ res.writeHead(415).end('Unexpected content type');
121
+ return;
122
+ }
123
+ if (req.method !== 'POST') {
124
+ res.writeHead(405).end('Unexpected request method');
125
+ return;
126
+ }
127
+ const { promise: bodyReady, resolve, reject } = Promise.withResolvers();
128
+ let body = '';
129
+ req.on('data', chunk => (body += chunk.toString()));
130
+ req.on('end', resolve);
131
+ req.on('error', reject);
132
+ try {
133
+ await bodyReady;
134
+ sessionReady.resolve(JSON.parse(body));
135
+ res.writeHead(200).end();
136
+ }
137
+ catch (e) {
138
+ res.statusCode = 500;
139
+ res.end('Internal server error: ' + e.message);
140
+ sessionReady.reject(e.message);
141
+ }
142
+ });
143
+ const serverReady = Promise.withResolvers();
144
+ server.listen(() => {
145
+ const { port } = server.address();
146
+ serverReady.resolve(port);
147
+ });
148
+ server.on('error', e => io.exit('Failed to start local callback server: ' + e.message, 5));
149
+ const port = await serverReady.promise;
150
+ const authURL = new URL('/login/client?port=' + port, url).href;
151
+ console.log('Authenticate by visiting this URL in your browser: ' + styleText('underline', authURL));
152
+ const { token } = await sessionReady.promise.catch(e => io.exit('Failed to obtain session: ' + e, 6));
153
+ setToken(token);
154
+ server.close();
155
+ io.start('Verifying session');
156
+ const session = await getCurrentSession().catch(e => io.exit(e.message, 6));
157
+ io.done();
158
+ io.debug('Session UUID: ' + session.id);
159
+ console.log(`Welcome ${session.user.name}! Your session is valid until ${session.expires.toLocaleDateString()}.`);
160
+ config.token = token;
161
+ config.server = url;
162
+ saveConfig();
163
+ await updateCache(true);
164
+ });
165
+ program.command('logout').action(async () => {
166
+ if (!config.token)
167
+ io.exit('Not logged in.', 4);
168
+ if (!config.cache)
169
+ io.exit('No session data available.', 3);
170
+ await logout(config.cache.session.userId, config.cache.session.id);
171
+ });
172
+ program.command('status').action(() => {
173
+ if (!config.token)
174
+ return console.log('Not logged in.');
175
+ if (!config.cache)
176
+ return console.log('No session data available.');
177
+ console.log('Logged in to', new URL(prefix).host);
178
+ console.log(styleText('whiteBright', 'Session ID:'), config.cache.session.id);
179
+ const { user } = config.cache.session;
180
+ console.log(styleText('whiteBright', 'User:'), user.name, `<${user.email}>`, styleText('dim', `(${user.id})`));
181
+ outputDaemonStatus('axium-client');
182
+ });
183
+ program
184
+ .command('run')
185
+ .argument('[plugin]', 'The plugin to run')
186
+ .action(async (name) => {
187
+ if (name) {
188
+ const plugin = _findPlugin(name);
189
+ await plugin._client?.run();
190
+ return;
191
+ }
192
+ for (const plugin of plugins.values())
193
+ await plugin._client?.run();
194
+ });
195
+ axiumPlugin = program.command('plugin').alias('plugins').description('Manage plugins');
196
+ axiumPlugin
197
+ .command('list')
198
+ .alias('ls')
199
+ .description('List loaded plugins')
200
+ .option('-l, --long', 'use the long listing format')
201
+ .option('--no-versions', 'do not show plugin versions')
202
+ .action((opt) => {
203
+ if (!plugins.size) {
204
+ console.log('No plugins loaded.');
205
+ return;
206
+ }
207
+ if (!opt.long) {
208
+ console.log(Array.from(plugins.keys()).join(', '));
209
+ return;
210
+ }
211
+ console.log(styleText('whiteBright', plugins.size + ' plugin(s) loaded:'));
212
+ for (const plugin of plugins.values()) {
213
+ console.log(plugin.name, opt.versions ? plugin.version : '');
214
+ }
215
+ });
216
+ axiumPlugin
217
+ .command('info')
218
+ .description('Get information about a plugin')
219
+ .argument('<plugin>', 'the plugin to get information about')
220
+ .action((search) => {
221
+ const plugin = _findPlugin(search);
222
+ for (const line of pluginText(plugin))
223
+ console.log(line);
224
+ });
225
+ axiumPlugin
226
+ .command('remove')
227
+ .alias('rm')
228
+ .description('Remove a plugin')
229
+ .argument('<plugin>', 'the plugin to remove')
230
+ .action((search) => {
231
+ const plugin = _findPlugin(search);
232
+ config.plugins = config.plugins.filter(p => p !== plugin.specifier);
233
+ plugins.delete(plugin.name);
234
+ saveConfig();
235
+ });
236
+ await program.parseAsync();
237
+ }
238
+ catch (e_1) {
239
+ env_1.error = e_1;
240
+ env_1.hasError = true;
241
+ }
242
+ finally {
243
+ __disposeResources(env_1);
244
+ }
@@ -0,0 +1,37 @@
1
+ import * as z from 'zod';
2
+ export declare const ClientConfig: z.ZodObject<{
3
+ token: z.ZodOptional<z.ZodNullable<z.ZodBase64>>;
4
+ server: z.ZodOptional<z.ZodNullable<z.ZodURL>>;
5
+ cache: z.ZodOptional<z.ZodNullable<z.ZodObject<{
6
+ fetched: z.ZodInt;
7
+ session: z.ZodObject<{
8
+ id: z.ZodUUID;
9
+ userId: z.ZodUUID;
10
+ expires: z.ZodCoercedDate<unknown>;
11
+ created: z.ZodCoercedDate<unknown>;
12
+ elevated: z.ZodBoolean;
13
+ user: z.ZodObject<{
14
+ id: z.ZodUUID;
15
+ name: z.ZodString;
16
+ email: z.ZodEmail;
17
+ emailVerified: z.ZodOptional<z.ZodNullable<z.ZodDate>>;
18
+ image: z.ZodOptional<z.ZodNullable<z.ZodURL>>;
19
+ preferences: z.ZodRecord<z.ZodString, z.ZodAny>;
20
+ roles: z.ZodArray<z.ZodString>;
21
+ registeredAt: z.ZodCoercedDate<unknown>;
22
+ isAdmin: z.ZodBoolean;
23
+ }, z.core.$strip>;
24
+ }, z.core.$strip>;
25
+ apps: z.ZodArray<z.ZodObject<{
26
+ id: z.ZodString;
27
+ name: z.ZodOptional<z.ZodString>;
28
+ image: z.ZodOptional<z.ZodString>;
29
+ icon: z.ZodOptional<z.ZodString>;
30
+ }, z.core.$strip>>;
31
+ }, z.core.$loose>>>;
32
+ plugins: z.ZodDefault<z.ZodArray<z.ZodString>>;
33
+ }, z.core.$loose>;
34
+ export interface ClientConfig extends z.infer<typeof ClientConfig> {
35
+ }
36
+ export declare const config: ClientConfig;
37
+ export declare function resolveServerURL(server: string): string;
package/dist/config.js ADDED
@@ -0,0 +1,33 @@
1
+ import { debug, warn } from '@axium/core/io';
2
+ import { App, Session, User } from '@axium/core';
3
+ import * as z from 'zod';
4
+ export const ClientConfig = z.looseObject({
5
+ token: z.base64().nullish(),
6
+ server: z.url().nullish(),
7
+ // Cache to reduce server load:
8
+ cache: z
9
+ .looseObject({
10
+ fetched: z.int(),
11
+ session: Session.extend({ user: User }),
12
+ apps: App.array(),
13
+ })
14
+ .nullish(),
15
+ plugins: z.string().array().default([]),
16
+ });
17
+ export const config = {
18
+ plugins: [],
19
+ };
20
+ export function resolveServerURL(server) {
21
+ if (!server.startsWith('http://') && !server.startsWith('https://'))
22
+ server = 'https://' + server;
23
+ const url = new URL(server);
24
+ if (url.pathname.endsWith('/api'))
25
+ url.pathname += '/';
26
+ else if (url.pathname.at(-1) == '/' && !url.pathname.endsWith('/api/'))
27
+ url.pathname += 'api/';
28
+ if (url.pathname != '/api/')
29
+ warn('Resolved server URL is not at the top level: ' + url.href);
30
+ else
31
+ debug('Resolved server URL: ' + url.href);
32
+ return url.href;
33
+ }
package/dist/requests.js CHANGED
@@ -16,6 +16,9 @@ export async function fetchAPI(method, endpoint, data, ...params) {
16
16
  };
17
17
  if (method !== 'GET' && method !== 'HEAD')
18
18
  options.body = JSON.stringify(data);
19
+ const search = method != 'GET' || typeof data != 'object' || data == null || !Object.keys(data).length
20
+ ? ''
21
+ : '?' + new URLSearchParams(data).toString();
19
22
  if (token)
20
23
  options.headers.Authorization = 'Bearer ' + token;
21
24
  const parts = [];
@@ -29,7 +32,7 @@ export async function fetchAPI(method, endpoint, data, ...params) {
29
32
  throw new Error(`Missing parameter "${part.slice(1)}"`);
30
33
  parts.push(value);
31
34
  }
32
- const response = await fetch(prefix + parts.join('/'), options);
35
+ const response = await fetch(prefix + parts.join('/') + search, options);
33
36
  if (!response.headers.get('Content-Type')?.includes('application/json')) {
34
37
  throw new Error(`Unexpected response type: ${response.headers.get('Content-Type')}`);
35
38
  }
package/dist/user.d.ts CHANGED
@@ -13,7 +13,7 @@ export declare function getSessions(userId: string): Promise<Session[]>;
13
13
  export declare function logout(userId: string, ...sessionId: string[]): Promise<Session[]>;
14
14
  export declare function logoutAll(userId: string): Promise<Session[]>;
15
15
  export declare function logoutCurrentSession(): Promise<Session>;
16
- export declare function register(_data: Record<string, FormDataEntryValue>): Promise<void>;
16
+ export declare function register(_data: Record<string, unknown>): Promise<void>;
17
17
  export declare function userInfo(userId: string): Promise<UserPublic & Partial<User>>;
18
18
  export declare function updateUser(userId: string, data: Record<string, FormDataEntryValue>): Promise<User>;
19
19
  export declare function fullUserInfo(userId: string): Promise<User & {
@@ -1,10 +1,9 @@
1
1
  <script lang="ts">
2
- import FormDialog from './FormDialog.svelte';
2
+ import type { User } from '@axium/core';
3
3
  import { permissionNames, type AccessControllable } from '@axium/core/access';
4
4
  import type { Entries } from 'utilium';
5
+ import FormDialog from './FormDialog.svelte';
5
6
  import UserCard from './UserCard.svelte';
6
- import type { Permission, AccessControl } from '@axium/core/access';
7
- import type { User } from '@axium/core';
8
7
 
9
8
  let {
10
9
  item = $bindable(),
@@ -1,14 +1,6 @@
1
1
  <script lang="ts">
2
2
  import Dialog from './Dialog.svelte';
3
3
 
4
- function resolveRedirectAfter() {
5
- const url = new URL(location.href);
6
- const maybe = url.searchParams.get('after');
7
- if (!maybe || maybe == url.pathname) return '/';
8
- for (const prefix of ['/api/']) if (maybe.startsWith(prefix)) return '/';
9
- return maybe || '/';
10
- }
11
-
12
4
  let {
13
5
  children,
14
6
  dialog = $bindable(),
@@ -52,8 +44,7 @@
52
44
  const data = Object.fromEntries(new FormData(e.currentTarget));
53
45
  submit(data)
54
46
  .then(result => {
55
- if (pageMode) window.location.href = resolveRedirectAfter();
56
- else dialog!.close();
47
+ if (!pageMode) dialog!.close();
57
48
  })
58
49
  .catch((e: any) => {
59
50
  if (!e) error = 'An unknown error occurred';
package/lib/Login.svelte CHANGED
@@ -1,15 +1,17 @@
1
1
  <script lang="ts">
2
2
  import { loginByEmail } from '@axium/client/user';
3
3
  import FormDialog from './FormDialog.svelte';
4
+ import redirectAfter from './auth_redirect.js';
4
5
 
5
6
  let { dialog = $bindable(), fullPage = false }: { dialog?: HTMLDialogElement; fullPage?: boolean } = $props();
6
7
 
7
- function submit(data: { email: string }) {
8
+ async function submit(data: { email: string }) {
8
9
  if (typeof data.email != 'string') {
9
10
  throw 'Tried to upload a file for an email. Huh?!';
10
11
  }
11
12
 
12
- return loginByEmail(data.email);
13
+ await loginByEmail(data.email);
14
+ if (fullPage && redirectAfter) location.href = redirectAfter;
13
15
  }
14
16
  </script>
15
17
 
@@ -3,7 +3,9 @@
3
3
  </script>
4
4
 
5
5
  <div class="Bar">
6
- <div class="fill" style="width: {((value - min) / (max - min)) * 100}%"></div>
6
+ {#if max}
7
+ <div class="fill" style="width: {((value - min) / (max - min)) * 100}%"></div>
8
+ {/if}
7
9
  {#if text}<span class="text">{text}</span>{/if}
8
10
  </div>
9
11
 
@@ -1,11 +1,17 @@
1
1
  <script lang="ts">
2
2
  import { register } from '@axium/client/user';
3
3
  import FormDialog from './FormDialog.svelte';
4
+ import redirectAfter from './auth_redirect.js';
4
5
 
5
6
  let { dialog = $bindable(), fullPage = false }: { dialog?: HTMLDialogElement; fullPage?: boolean } = $props();
7
+
8
+ async function submit(data: Record<string, FormDataEntryValue>) {
9
+ await register(data);
10
+ if (fullPage && redirectAfter) location.href = redirectAfter;
11
+ }
6
12
  </script>
7
13
 
8
- <FormDialog bind:dialog submitText="Register" submit={register} pageMode={fullPage}>
14
+ <FormDialog bind:dialog submitText="Register" {submit} pageMode={fullPage}>
9
15
  <div>
10
16
  <label for="name">Display Name</label>
11
17
  <input name="name" type="text" required />
@@ -0,0 +1,57 @@
1
+ <script lang="ts">
2
+ import { logout, logoutAll } from '@axium/client/user';
3
+ import type { Session, User } from '@axium/core';
4
+ import FormDialog from './FormDialog.svelte';
5
+ import Icon from './Icon.svelte';
6
+
7
+ let {
8
+ sessions = $bindable(),
9
+ currentSession,
10
+ user,
11
+ redirectAfterLogoutAll = false,
12
+ }: { sessions: Session[]; currentSession?: Session; user: User; redirectAfterLogoutAll?: boolean } = $props();
13
+
14
+ const dialogs = $state<Record<string, HTMLDialogElement>>({});
15
+ </script>
16
+
17
+ {#each sessions as session}
18
+ <div class="item session">
19
+ <p>
20
+ {session.id.slice(0, 4)}...{session.id.slice(-4)}
21
+ {#if session.id == currentSession?.id}
22
+ <span class="current">Current</span>
23
+ {/if}
24
+ {#if session.elevated}
25
+ <span class="elevated">Elevated</span>
26
+ {/if}
27
+ </p>
28
+ <p>Created {session.created.toLocaleString()}</p>
29
+ <p>Expires {session.expires.toLocaleString()}</p>
30
+ <button style:display="contents" onclick={() => dialogs['logout#' + session.id].showModal()}>
31
+ <Icon i="right-from-bracket" --size="16px" />
32
+ </button>
33
+ </div>
34
+ <FormDialog
35
+ bind:dialog={dialogs['logout#' + session.id]}
36
+ submit={async () => {
37
+ await logout(user.id, session.id);
38
+ dialogs['logout#' + session.id].remove();
39
+ sessions.splice(sessions.indexOf(session), 1);
40
+ if (session.id == currentSession?.id) window.location.href = '/';
41
+ }}
42
+ submitText="Logout"
43
+ >
44
+ <p>Are you sure you want to log out this session?</p>
45
+ </FormDialog>
46
+ {/each}
47
+ <span>
48
+ <button onclick={() => dialogs.logout_all.showModal()} class="danger">Logout All</button>
49
+ </span>
50
+ <FormDialog
51
+ bind:dialog={dialogs.logout_all}
52
+ submit={() => logoutAll(user.id).then(() => (redirectAfterLogoutAll ? (window.location.href = '/') : null))}
53
+ submitText="Logout All Sessions"
54
+ submitDanger
55
+ >
56
+ <p>Are you sure you want to log out all sessions?</p>
57
+ </FormDialog>
@@ -0,0 +1,75 @@
1
+ <script lang="ts">
2
+ import type { User } from '@axium/core/user';
3
+ import { getUserImage } from '@axium/core';
4
+ import { fetchAPI } from '@axium/client/requests';
5
+ import Icon from './Icon.svelte';
6
+ import Popover from './Popover.svelte';
7
+ import Logout from './Logout.svelte';
8
+
9
+ const { user }: { user: Partial<User> } = $props();
10
+
11
+ let logout = $state<HTMLDialogElement>()!;
12
+ </script>
13
+
14
+ <Popover>
15
+ {#snippet toggle()}
16
+ <div style:display="contents">
17
+ <img src={getUserImage(user)} alt={user.name} />
18
+ {user.name}
19
+ </div>
20
+ {/snippet}
21
+
22
+ <a class="menu-item" href="/account">
23
+ <Icon i="user" --size="1.5em" />
24
+ <span>Your Account</span>
25
+ </a>
26
+
27
+ {#if user.isAdmin}
28
+ <a class="menu-item" href="/admin">
29
+ <Icon i="gear-complex" --size="1.5em" />
30
+ <span>Administration</span>
31
+ </a>
32
+ {/if}
33
+
34
+ {#await fetchAPI('GET', 'apps')}
35
+ <i>Loading...</i>
36
+ {:then apps}
37
+ {#each apps as app}
38
+ <a class="menu-item" href="/{app.id}">
39
+ {#if app.image}
40
+ <img src={app.image} alt={app.name} width="1em" height="1em" />
41
+ {:else if app.icon}
42
+ <Icon i={app.icon} --size="1.5em" />
43
+ {:else}
44
+ <Icon i="image-circle-xmark" --size="1.5em" />
45
+ {/if}
46
+ <span>{app.name}</span>
47
+ </a>
48
+ {:else}
49
+ <i>No apps available.</i>
50
+ {/each}
51
+ {:catch}
52
+ <i>Couldn't load apps.</i>
53
+ {/await}
54
+
55
+ <span class="menu-item logout" onclick={() => logout.showModal()}>
56
+ <Icon i="right-from-bracket" --size="1.5em" --fill="hsl(0 33 var(--fg-light))" />
57
+ <span>Logout</span>
58
+ </span>
59
+ </Popover>
60
+
61
+ <Logout bind:dialog={logout} />
62
+
63
+ <style>
64
+ img {
65
+ width: 2em;
66
+ height: 2em;
67
+ border-radius: 50%;
68
+ vertical-align: middle;
69
+ margin-right: 0.5em;
70
+ }
71
+
72
+ span.logout > span {
73
+ color: hsl(0 33 var(--fg-light));
74
+ }
75
+ </style>
@@ -0,0 +1,28 @@
1
+ import { getCurrentSession } from '@axium/client/user';
2
+
3
+ function resolveRedirect(): string | false {
4
+ const url = new URL(location.href);
5
+ const maybe = url.searchParams.get('after');
6
+ if (!['/login', '/register'].includes(url.pathname)) return false;
7
+ if (!maybe || maybe == url.pathname) return '/';
8
+
9
+ if (maybe[0] != '/' || maybe[1] == '/') {
10
+ console.error('Ignoring potentially malicious redirect:', maybe);
11
+ return false;
12
+ }
13
+
14
+ const redirect = new URL(maybe, location.origin);
15
+
16
+ return redirect.pathname + redirect.search || '/';
17
+ }
18
+
19
+ const redirect = resolveRedirect();
20
+
21
+ try {
22
+ if (!redirect) throw 'No redirect';
23
+ // Auto-redirect if already logged in.
24
+ const session = await getCurrentSession();
25
+ if (session) location.href = redirect;
26
+ } catch {}
27
+
28
+ export default redirect;
package/lib/index.ts CHANGED
@@ -11,7 +11,9 @@ export { default as Popover } from './Popover.svelte';
11
11
  export { default as Preference } from './Preference.svelte';
12
12
  export { default as Preferences } from './Preferences.svelte';
13
13
  export { default as Register } from './Register.svelte';
14
+ export { default as SessionList } from './SessionList.svelte';
14
15
  export { default as Toast } from './Toast.svelte';
15
16
  export { default as Upload } from './Upload.svelte';
16
17
  export { default as UserCard } from './UserCard.svelte';
18
+ export { default as UserMenu } from './UserMenu.svelte';
17
19
  export { default as WithContextMenu } from './WithContextMenu.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axium/client",
3
- "version": "0.4.9",
3
+ "version": "0.6.0",
4
4
  "author": "James Prevett <jp@jamespre.dev>",
5
5
  "funding": {
6
6
  "type": "individual",
@@ -15,25 +15,32 @@
15
15
  "bugs": {
16
16
  "url": "https://github.com/james-pre/axium/issues"
17
17
  },
18
+ "bin": {
19
+ "axium-client": "dist/cli/index.js",
20
+ "axc": "dist/cli/index.js"
21
+ },
18
22
  "type": "module",
19
23
  "main": "dist/index.js",
20
24
  "types": "dist/index.d.ts",
21
25
  "files": [
22
26
  "assets",
23
27
  "dist",
24
- "lib"
28
+ "lib",
29
+ "styles",
30
+ "axium-client.service"
25
31
  ],
26
32
  "exports": {
27
33
  ".": "./dist/index.js",
28
34
  "./*": "./dist/*.js",
29
35
  "./components": "./lib/index.js",
30
- "./components/*": "./lib/*.svelte"
36
+ "./components/*": "./lib/*.svelte",
37
+ "./styles/*": "./styles/*.css"
31
38
  },
32
39
  "scripts": {
33
40
  "build": "tsc"
34
41
  },
35
42
  "peerDependencies": {
36
- "@axium/core": ">=0.6.0",
43
+ "@axium/core": ">=0.9.0",
37
44
  "utilium": "^2.3.8",
38
45
  "zod": "^4.0.5",
39
46
  "svelte": "^5.36.0"
@@ -0,0 +1,57 @@
1
+ .section {
2
+ width: 50%;
3
+ padding-top: 4em;
4
+
5
+ /* This is causing duplicate separators when removing sessions/passkeys
6
+ > div:has(+ div) {
7
+ border-bottom: 1px solid #8888;
8
+ }
9
+ */
10
+ }
11
+
12
+ .section .item {
13
+ display: grid;
14
+ align-items: center;
15
+ width: 100%;
16
+ gap: 1em;
17
+ text-wrap: nowrap;
18
+ border-top: 1px solid #8888;
19
+ padding-bottom: 1em;
20
+ }
21
+
22
+ .info {
23
+ grid-template-columns: 10em 1fr 2em;
24
+
25
+ > :first-child {
26
+ margin-left: 1em;
27
+ }
28
+
29
+ > :nth-child(2) {
30
+ text-overflow: ellipsis;
31
+ overflow: hidden;
32
+ }
33
+ }
34
+
35
+ .passkey {
36
+ grid-template-columns: 1em 1em 1fr 1fr 1em 1em;
37
+
38
+ dfn:not(.disabled) {
39
+ cursor: help;
40
+ }
41
+ }
42
+
43
+ .session {
44
+ grid-template-columns: 1fr 1fr 1fr 1em;
45
+
46
+ .current {
47
+ border-radius: 2em;
48
+ padding: 0 0.5em;
49
+ background-color: #337;
50
+ }
51
+
52
+ .elevated {
53
+ border-radius: 2em;
54
+ padding: 0 0.5em;
55
+ background-color: #733;
56
+ }
57
+ }
@@ -0,0 +1,61 @@
1
+ .list-container {
2
+ overflow-x: hidden;
3
+ overflow-y: scroll;
4
+
5
+ .list {
6
+ height: 100%;
7
+ }
8
+ }
9
+
10
+ .list {
11
+ display: flex;
12
+ flex-direction: column;
13
+ padding: 0.5em;
14
+ }
15
+
16
+ .list-header {
17
+ font-weight: bold;
18
+ border-bottom: 1.5px solid var(--fg-accent);
19
+ position: sticky;
20
+ top: 0em;
21
+ }
22
+
23
+ .list-item-container {
24
+ text-decoration: none;
25
+ color: inherit;
26
+ }
27
+
28
+ .list-item {
29
+ display: grid;
30
+ grid-template-columns: 1em 4fr 15em 5em repeat(3, 1em);
31
+ align-items: center;
32
+ gap: 1em;
33
+ padding: 0.5em;
34
+ overflow: hidden;
35
+ text-wrap: nowrap;
36
+ }
37
+
38
+ .list-item:not(.list-header, :first-child) {
39
+ border-top: 1px solid var(--fg-accent);
40
+ }
41
+
42
+ .list-item:not(.list-header):hover {
43
+ background-color: var(--bg-alt);
44
+ cursor: pointer;
45
+ }
46
+
47
+ p.list-empty {
48
+ text-align: center;
49
+ color: #888;
50
+ margin-top: 1em;
51
+ font-style: italic;
52
+ }
53
+
54
+ .list-item .action {
55
+ visibility: hidden;
56
+ }
57
+
58
+ .list-item:hover .action {
59
+ visibility: visible;
60
+ cursor: pointer;
61
+ }