@ternkit/tern 1.4.0 → 1.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/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/runtime/builtin.d.ts +1 -0
- package/dist/runtime/builtin.js +140 -0
- package/dist/runtime/deferred.d.ts +6 -1
- package/dist/runtime/deferred.js +31 -13
- package/dist/runtime/i18n.svelte.d.ts +6 -0
- package/dist/runtime/i18n.svelte.js +10 -0
- package/dist/runtime/passkeys.d.ts +21 -0
- package/dist/runtime/passkeys.js +107 -0
- package/dist/runtime/router.svelte.js +2 -1
- package/dist/runtime/types.d.ts +2 -0
- package/dist/runtime/validate.d.ts +2 -1
- package/dist/runtime/validate.js +20 -18
- package/dist/server/index.d.ts +3 -1
- package/dist/server/index.js +8 -4
- package/dist/ui/AccountSection.svelte +75 -0
- package/dist/ui/AccountSection.svelte.d.ts +12 -0
- package/dist/ui/AlertDialog.svelte +5 -2
- package/dist/ui/AuthCard.svelte +64 -0
- package/dist/ui/AuthCard.svelte.d.ts +12 -0
- package/dist/ui/BarChart.svelte +2 -1
- package/dist/ui/Breadcrumb.svelte +2 -1
- package/dist/ui/Calendar.svelte +3 -2
- package/dist/ui/Carousel.svelte +4 -3
- package/dist/ui/ChartFrame.svelte +2 -1
- package/dist/ui/Combobox.svelte +6 -3
- package/dist/ui/Command.svelte +5 -1
- package/dist/ui/ConsentScreen.svelte +7 -6
- package/dist/ui/DataTable.svelte +7 -5
- package/dist/ui/DatePicker.svelte +8 -5
- package/dist/ui/Devices.svelte +50 -0
- package/dist/ui/Devices.svelte.d.ts +23 -0
- package/dist/ui/Dialog.svelte +2 -1
- package/dist/ui/DonutChart.svelte +3 -1
- package/dist/ui/Field.svelte +1 -1
- package/dist/ui/ForgotPassword.svelte +43 -0
- package/dist/ui/ForgotPassword.svelte.d.ts +16 -0
- package/dist/ui/Form.svelte +8 -3
- package/dist/ui/NavigationMenu.svelte +3 -1
- package/dist/ui/Pagination.svelte +5 -3
- package/dist/ui/Passkeys.svelte +73 -0
- package/dist/ui/Passkeys.svelte.d.ts +21 -0
- package/dist/ui/PasswordInput.svelte +2 -1
- package/dist/ui/ResetPassword.svelte +54 -0
- package/dist/ui/ResetPassword.svelte.d.ts +23 -0
- package/dist/ui/Resizable.svelte +3 -1
- package/dist/ui/SelectMenu.svelte +5 -2
- package/dist/ui/Sidebar.svelte +5 -3
- package/dist/ui/SignIn.svelte +48 -12
- package/dist/ui/SignIn.svelte.d.ts +4 -0
- package/dist/ui/SignUp.svelte +10 -8
- package/dist/ui/Slider.svelte +2 -1
- package/dist/ui/Spinner.svelte +3 -1
- package/dist/ui/Switch.svelte +2 -1
- package/dist/ui/Table.svelte +3 -1
- package/dist/ui/Toaster.svelte +3 -2
- package/dist/ui/Toggle.svelte +2 -0
- package/dist/ui/ToggleGroup.svelte +2 -0
- package/dist/ui/accountDates.d.ts +1 -0
- package/dist/ui/accountDates.js +15 -0
- package/dist/ui/announce.d.ts +4 -0
- package/dist/ui/announce.js +14 -0
- package/dist/ui/index.d.ts +4 -0
- package/dist/ui/index.js +4 -0
- package/dist/vite/index.js +2 -1
- package/dist/vite/native.d.ts +3 -0
- package/dist/vite/native.js +139 -0
- package/package.json +3 -2
package/dist/index.d.ts
CHANGED
|
@@ -21,6 +21,7 @@ export type { ConnectionStatus, StreamOptions, SocketOptions } from './runtime/r
|
|
|
21
21
|
export { default as Image } from './image/Image.svelte';
|
|
22
22
|
export type { Picture } from './image/types.js';
|
|
23
23
|
export { t, i18n, setLocale } from './runtime/i18n.svelte.js';
|
|
24
|
+
export { passkeysSupported, registerPasskey, signInWithPasskey, PasskeyError, type PasskeyOptions } from './runtime/passkeys.js';
|
|
24
25
|
export type { MessageKey } from './runtime/i18n.svelte.js';
|
|
25
26
|
/**
|
|
26
27
|
* The app's message keys. `tern gen` fills this in from src/locales, so
|
|
@@ -29,4 +30,4 @@ export type { MessageKey } from './runtime/i18n.svelte.js';
|
|
|
29
30
|
export interface Messages {
|
|
30
31
|
}
|
|
31
32
|
/** The version of the Tern client runtime. */
|
|
32
|
-
export declare const VERSION = "1.
|
|
33
|
+
export declare const VERSION = "1.6.0";
|
package/dist/index.js
CHANGED
|
@@ -16,5 +16,6 @@ export { ApiError, createApi } from './runtime/api.js';
|
|
|
16
16
|
export { stream, socket, EventStreamState, SocketState } from './runtime/realtime.svelte.js';
|
|
17
17
|
export { default as Image } from './image/Image.svelte';
|
|
18
18
|
export { t, i18n, setLocale } from './runtime/i18n.svelte.js';
|
|
19
|
+
export { passkeysSupported, registerPasskey, signInWithPasskey, PasskeyError } from './runtime/passkeys.js';
|
|
19
20
|
/** The version of the Tern client runtime. */
|
|
20
|
-
export const VERSION = '1.
|
|
21
|
+
export const VERSION = '1.6.0';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const builtin: Record<string, string>;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Tern's own texts, in English: Tern UI's and the validation messages.
|
|
2
|
+
// An app translates them in src/locales like its own messages. Generated
|
|
3
|
+
// from internal/i18n/builtin.json; a Go test keeps the two the same.
|
|
4
|
+
export const builtin = {
|
|
5
|
+
"tern.accounts.error.credentials": "Wrong email or password.",
|
|
6
|
+
"tern.accounts.error.emailTaken": "An account with this email address already exists.",
|
|
7
|
+
"tern.accounts.error.invalidToken": "This link is invalid or has expired.",
|
|
8
|
+
"tern.accounts.error.passkey": "That passkey didn’t work.",
|
|
9
|
+
"tern.accounts.error.wrongPassword": "That isn’t your current password.",
|
|
10
|
+
"tern.accounts.reset.button": "Choose a new password",
|
|
11
|
+
"tern.accounts.reset.subject": "Reset your password",
|
|
12
|
+
"tern.accounts.reset.text": "Hello {name},\n\nSomeone asked to reset your password. If it was you, choose a new one here within an hour:\n{link}\n\nIf it wasn’t you, ignore this email: your password stays as it is.",
|
|
13
|
+
"tern.accounts.verify.button": "Confirm my email address",
|
|
14
|
+
"tern.accounts.verify.subject": "Confirm your email address",
|
|
15
|
+
"tern.accounts.verify.text": "Hello {name},\n\nConfirm your email address by opening this link:\n{link}\n\nIf you didn’t create an account, you can ignore this email.",
|
|
16
|
+
"tern.ui.alertDialog.cancel": "Cancel",
|
|
17
|
+
"tern.ui.alertDialog.confirm": "Continue",
|
|
18
|
+
"tern.ui.barChart.total": "Total",
|
|
19
|
+
"tern.ui.breadcrumb.label": "Breadcrumb",
|
|
20
|
+
"tern.ui.calendar.nextMonth": "Next month",
|
|
21
|
+
"tern.ui.calendar.previousMonth": "Previous month",
|
|
22
|
+
"tern.ui.carousel.label": "Slides",
|
|
23
|
+
"tern.ui.carousel.next": "Next slide",
|
|
24
|
+
"tern.ui.carousel.previous": "Previous slide",
|
|
25
|
+
"tern.ui.chart.legend": "Legend",
|
|
26
|
+
"tern.ui.combobox.empty": "No results",
|
|
27
|
+
"tern.ui.combobox.searching": "Searching…",
|
|
28
|
+
"tern.ui.command.empty": "No results",
|
|
29
|
+
"tern.ui.command.label": "Commands",
|
|
30
|
+
"tern.ui.command.placeholder": "Type a command or search…",
|
|
31
|
+
"tern.ui.consent.allow": "Allow",
|
|
32
|
+
"tern.ui.consent.deny": "Deny",
|
|
33
|
+
"tern.ui.consent.notYou": "Not you?",
|
|
34
|
+
"tern.ui.consent.note": "You can take this back at any time in your account settings.",
|
|
35
|
+
"tern.ui.consent.scopes": "It will be able to:",
|
|
36
|
+
"tern.ui.consent.title": "{client} wants to access your account",
|
|
37
|
+
"tern.ui.dataTable.actions": "Actions",
|
|
38
|
+
"tern.ui.dataTable.empty": "No rows match",
|
|
39
|
+
"tern.ui.dataTable.search": "Search…",
|
|
40
|
+
"tern.ui.dataTable.searchLabel": "Search {caption}",
|
|
41
|
+
"tern.ui.dataTable.selectAll": "Select all rows on this page",
|
|
42
|
+
"tern.ui.datePicker.label": "Choose a date",
|
|
43
|
+
"tern.ui.datePicker.placeholder": "Pick a date",
|
|
44
|
+
"tern.ui.datePicker.placeholderRange": "Pick dates",
|
|
45
|
+
"tern.ui.devices.current": "This device",
|
|
46
|
+
"tern.ui.devices.description": "Where your account is signed in. Sign out any device you don’t recognize.",
|
|
47
|
+
"tern.ui.devices.lastSeen": "Last active {when}",
|
|
48
|
+
"tern.ui.devices.signOut": "Sign out",
|
|
49
|
+
"tern.ui.devices.signOutOthers": "Sign out all other devices",
|
|
50
|
+
"tern.ui.devices.title": "Signed-in devices",
|
|
51
|
+
"tern.ui.dialog.close": "Close",
|
|
52
|
+
"tern.ui.donutChart.total": "total",
|
|
53
|
+
"tern.ui.forgotPassword.back": "Back to sign in",
|
|
54
|
+
"tern.ui.forgotPassword.description": "Enter your account’s email address and we’ll send you a link to choose a new password.",
|
|
55
|
+
"tern.ui.forgotPassword.email": "Email",
|
|
56
|
+
"tern.ui.forgotPassword.sent": "If an account uses that address, a link to reset its password is on its way. Check your inbox.",
|
|
57
|
+
"tern.ui.forgotPassword.submit": "Send link",
|
|
58
|
+
"tern.ui.forgotPassword.title": "Reset your password",
|
|
59
|
+
"tern.ui.form.summary": "{n, plural, one {# field needs attention.} other {# fields need attention.}}",
|
|
60
|
+
"tern.ui.navigationMenu.label": "Main",
|
|
61
|
+
"tern.ui.pagination.label": "Pagination",
|
|
62
|
+
"tern.ui.pagination.next": "Next ›",
|
|
63
|
+
"tern.ui.pagination.nextLabel": "Next page",
|
|
64
|
+
"tern.ui.pagination.previous": "‹ Previous",
|
|
65
|
+
"tern.ui.pagination.previousLabel": "Previous page",
|
|
66
|
+
"tern.ui.passkeys.add": "Add a passkey",
|
|
67
|
+
"tern.ui.passkeys.added": "Added {when}",
|
|
68
|
+
"tern.ui.passkeys.description": "Sign in with your fingerprint, face or screen lock instead of a password.",
|
|
69
|
+
"tern.ui.passkeys.empty": "No passkeys yet.",
|
|
70
|
+
"tern.ui.passkeys.failed": "The passkey wasn’t added. Try again.",
|
|
71
|
+
"tern.ui.passkeys.lastUsed": "last used {when}",
|
|
72
|
+
"tern.ui.passkeys.never": "never used",
|
|
73
|
+
"tern.ui.passkeys.remove": "Remove",
|
|
74
|
+
"tern.ui.passkeys.title": "Passkeys",
|
|
75
|
+
"tern.ui.passkeys.unsupported": "This browser can’t use passkeys.",
|
|
76
|
+
"tern.ui.password.show": "Show password",
|
|
77
|
+
"tern.ui.resetPassword.again": "Ask for a new link",
|
|
78
|
+
"tern.ui.resetPassword.confirm": "Confirm new password",
|
|
79
|
+
"tern.ui.resetPassword.invalid": "This link is invalid or has expired.",
|
|
80
|
+
"tern.ui.resetPassword.password": "New password",
|
|
81
|
+
"tern.ui.resetPassword.submit": "Save password",
|
|
82
|
+
"tern.ui.resetPassword.title": "Choose a new password",
|
|
83
|
+
"tern.ui.resizable.label": "Resize",
|
|
84
|
+
"tern.ui.selectMenu.placeholder": "Choose…",
|
|
85
|
+
"tern.ui.sidebar.label": "Main",
|
|
86
|
+
"tern.ui.sidebar.open": "Open navigation",
|
|
87
|
+
"tern.ui.sidebar.title": "Navigation",
|
|
88
|
+
"tern.ui.signIn.cancelled": "You cancelled the sign-in. Try again when you’re ready.",
|
|
89
|
+
"tern.ui.signIn.continueWith": "Continue with {provider}",
|
|
90
|
+
"tern.ui.signIn.email": "Email",
|
|
91
|
+
"tern.ui.signIn.failed": "Signing in didn’t work. Try again, or choose another way.",
|
|
92
|
+
"tern.ui.signIn.forgot": "Forgot your password?",
|
|
93
|
+
"tern.ui.signIn.new": "New here?",
|
|
94
|
+
"tern.ui.signIn.or": "or",
|
|
95
|
+
"tern.ui.signIn.passkey": "Sign in with a passkey",
|
|
96
|
+
"tern.ui.signIn.passkeyFailed": "That passkey didn’t work. Try again, or sign in another way.",
|
|
97
|
+
"tern.ui.signIn.password": "Password",
|
|
98
|
+
"tern.ui.signIn.remember": "Keep me signed in",
|
|
99
|
+
"tern.ui.signIn.signUp": "Create an account",
|
|
100
|
+
"tern.ui.signIn.submit": "Sign in",
|
|
101
|
+
"tern.ui.signIn.title": "Sign in",
|
|
102
|
+
"tern.ui.signUp.agree": "I agree to the",
|
|
103
|
+
"tern.ui.signUp.confirm": "Confirm password",
|
|
104
|
+
"tern.ui.signUp.email": "Email",
|
|
105
|
+
"tern.ui.signUp.haveAccount": "Already have an account?",
|
|
106
|
+
"tern.ui.signUp.minPassword": "At least {n} characters.",
|
|
107
|
+
"tern.ui.signUp.name": "Name",
|
|
108
|
+
"tern.ui.signUp.password": "Password",
|
|
109
|
+
"tern.ui.signUp.signIn": "Sign in",
|
|
110
|
+
"tern.ui.signUp.submit": "Create account",
|
|
111
|
+
"tern.ui.signUp.terms": "terms",
|
|
112
|
+
"tern.ui.signUp.title": "Create an account",
|
|
113
|
+
"tern.ui.spinner.label": "Loading",
|
|
114
|
+
"tern.ui.table.empty": "Nothing to show",
|
|
115
|
+
"tern.ui.toaster.dismiss": "Dismiss",
|
|
116
|
+
"tern.ui.toaster.label": "Notifications",
|
|
117
|
+
"tern.validate.after": "{field} must be on or after {date}.",
|
|
118
|
+
"tern.validate.before": "{field} must be on or before {date}.",
|
|
119
|
+
"tern.validate.checked": "{field} must be checked.",
|
|
120
|
+
"tern.validate.date": "{field} must be a date.",
|
|
121
|
+
"tern.validate.datetime": "{field} must be a date and time.",
|
|
122
|
+
"tern.validate.email": "{field} must be an email address.",
|
|
123
|
+
"tern.validate.exactly": "{field} must be exactly {count}.",
|
|
124
|
+
"tern.validate.int": "{field} must be a whole number.",
|
|
125
|
+
"tern.validate.items": "Choose exactly {count} for {fieldLower}.",
|
|
126
|
+
"tern.validate.length": "{field} must be exactly {count} {n, plural, one {character} other {characters}}.",
|
|
127
|
+
"tern.validate.max": "{field} must be at most {count}.",
|
|
128
|
+
"tern.validate.maxItems": "Choose at most {count} for {fieldLower}.",
|
|
129
|
+
"tern.validate.maxLength": "{field} must be at most {count} {n, plural, one {character} other {characters}}.",
|
|
130
|
+
"tern.validate.min": "{field} must be at least {count}.",
|
|
131
|
+
"tern.validate.minItems": "Choose at least {count} for {fieldLower}.",
|
|
132
|
+
"tern.validate.minLength": "{field} must be at least {count} {n, plural, one {character} other {characters}}.",
|
|
133
|
+
"tern.validate.number": "{field} must be a number.",
|
|
134
|
+
"tern.validate.oneof": "{field} must be one of {options}.",
|
|
135
|
+
"tern.validate.or": "or",
|
|
136
|
+
"tern.validate.pattern": "{field} isn't in the expected format.",
|
|
137
|
+
"tern.validate.required": "{field} is required.",
|
|
138
|
+
"tern.validate.same": "{field} must match {otherLower}.",
|
|
139
|
+
"tern.validate.url": "{field} must be a web address starting with http:// or https://."
|
|
140
|
+
};
|
|
@@ -17,7 +17,11 @@ export declare class DeferredScope {
|
|
|
17
17
|
/** Rejects every value still pending, e.g. when the stream ends early. */
|
|
18
18
|
close(reason: string): void;
|
|
19
19
|
}
|
|
20
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Replaces deferred placeholders in data with promises from scope. The data
|
|
22
|
+
* must be fresh from JSON.parse: it is changed in place, which saves
|
|
23
|
+
* copying every page's data on every render.
|
|
24
|
+
*/
|
|
21
25
|
export declare function revive<T>(value: T, promise: (id: number) => Promise<unknown>): T;
|
|
22
26
|
/**
|
|
23
27
|
* Reads a response that holds one JSON payload line, optionally followed
|
|
@@ -26,4 +30,5 @@ export declare function revive<T>(value: T, promise: (id: number) => Promise<unk
|
|
|
26
30
|
*/
|
|
27
31
|
export declare function readStreamed<P extends {
|
|
28
32
|
nodes?: unknown;
|
|
33
|
+
deferred?: boolean;
|
|
29
34
|
}>(res: Response): Promise<P>;
|
package/dist/runtime/deferred.js
CHANGED
|
@@ -32,21 +32,37 @@ export class DeferredScope {
|
|
|
32
32
|
this.#pending.clear();
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Replaces deferred placeholders in data with promises from scope. The data
|
|
37
|
+
* must be fresh from JSON.parse: it is changed in place, which saves
|
|
38
|
+
* copying every page's data on every render.
|
|
39
|
+
*/
|
|
36
40
|
export function revive(value, promise) {
|
|
41
|
+
if (value === null || typeof value !== 'object')
|
|
42
|
+
return value;
|
|
37
43
|
if (Array.isArray(value)) {
|
|
38
|
-
|
|
44
|
+
for (let i = 0; i < value.length; i++) {
|
|
45
|
+
const v = value[i];
|
|
46
|
+
if (v !== null && typeof v === 'object') {
|
|
47
|
+
const r = revive(v, promise);
|
|
48
|
+
if (r !== v)
|
|
49
|
+
value[i] = r;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
39
53
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
54
|
+
const obj = value;
|
|
55
|
+
if (typeof obj.__tern_deferred === 'number' && Object.keys(obj).length === 1) {
|
|
56
|
+
return promise(obj.__tern_deferred);
|
|
57
|
+
}
|
|
58
|
+
for (const k in obj) {
|
|
59
|
+
const v = obj[k];
|
|
60
|
+
if (v !== null && typeof v === 'object') {
|
|
61
|
+
const r = revive(v, promise);
|
|
62
|
+
// defineProperty, so a key named __proto__ stays a plain property.
|
|
63
|
+
if (r !== v)
|
|
64
|
+
Object.defineProperty(obj, k, { value: r, writable: true, enumerable: true, configurable: true });
|
|
45
65
|
}
|
|
46
|
-
const out = {};
|
|
47
|
-
for (const k of keys)
|
|
48
|
-
out[k] = revive(obj[k], promise);
|
|
49
|
-
return out;
|
|
50
66
|
}
|
|
51
67
|
return value;
|
|
52
68
|
}
|
|
@@ -60,7 +76,8 @@ export async function readStreamed(res) {
|
|
|
60
76
|
const [first, ...rest] = (await res.text()).split('\n');
|
|
61
77
|
const scope = new DeferredScope();
|
|
62
78
|
const payload = JSON.parse(first);
|
|
63
|
-
|
|
79
|
+
if (payload.deferred)
|
|
80
|
+
payload.nodes = revive(payload.nodes, (id) => scope.promise(id));
|
|
64
81
|
for (const line of rest)
|
|
65
82
|
if (line.trim())
|
|
66
83
|
scope.settle(JSON.parse(line));
|
|
@@ -82,7 +99,8 @@ export async function readStreamed(res) {
|
|
|
82
99
|
buffer = newline < 0 ? '' : buffer.slice(newline + 1);
|
|
83
100
|
const scope = new DeferredScope();
|
|
84
101
|
const payload = JSON.parse(first);
|
|
85
|
-
|
|
102
|
+
if (payload.deferred)
|
|
103
|
+
payload.nodes = revive(payload.nodes, (id) => scope.promise(id));
|
|
86
104
|
void (async () => {
|
|
87
105
|
try {
|
|
88
106
|
for (;;) {
|
|
@@ -32,6 +32,12 @@ export declare function useLocaleSync(locale: string | undefined): void;
|
|
|
32
32
|
* then to the key.
|
|
33
33
|
*/
|
|
34
34
|
export declare function t(key: MessageKey, params?: Record<string, unknown>): string;
|
|
35
|
+
/**
|
|
36
|
+
* Translates one of Tern's own texts, such as Tern UI's labels and the
|
|
37
|
+
* validation messages: the app's message for key in src/locales if it has
|
|
38
|
+
* one, else Tern's English. Their keys start with "tern.".
|
|
39
|
+
*/
|
|
40
|
+
export declare function tb(key: string, params?: Record<string, unknown>): string;
|
|
35
41
|
/** The current locale and the ones the app supports. */
|
|
36
42
|
export declare const i18n: {
|
|
37
43
|
/** The current locale, such as "de", or "" if the app has no src/locales. */
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { builtin } from './builtin.js';
|
|
1
2
|
import { format } from './format.js';
|
|
2
3
|
class I18nState {
|
|
3
4
|
locale = $state('');
|
|
@@ -78,6 +79,15 @@ export function t(key, params) {
|
|
|
78
79
|
}
|
|
79
80
|
return format(state.locale || config?.defaultLocale || 'en', message, params);
|
|
80
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Translates one of Tern's own texts, such as Tern UI's labels and the
|
|
84
|
+
* validation messages: the app's message for key in src/locales if it has
|
|
85
|
+
* one, else Tern's English. Their keys start with "tern.".
|
|
86
|
+
*/
|
|
87
|
+
export function tb(key, params) {
|
|
88
|
+
const message = state.messages[key] ?? state.fallback[key] ?? builtin[key] ?? key;
|
|
89
|
+
return format(state.locale || config?.defaultLocale || 'en', message, params);
|
|
90
|
+
}
|
|
81
91
|
/** The current locale and the ones the app supports. */
|
|
82
92
|
export const i18n = {
|
|
83
93
|
/** The current locale, such as "de", or "" if the app has no src/locales. */
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type PasskeyOptions = {
|
|
2
|
+
/** Where the accounts package mounts its passkey endpoints (Options.PasskeyPath). */
|
|
3
|
+
path?: string;
|
|
4
|
+
};
|
|
5
|
+
/** A passkey ceremony that didn't finish. cancelled is true when the user closed the browser's prompt. */
|
|
6
|
+
export declare class PasskeyError extends Error {
|
|
7
|
+
cancelled: boolean;
|
|
8
|
+
constructor(message: string, cancelled?: boolean);
|
|
9
|
+
}
|
|
10
|
+
/** Whether this browser can use passkeys. */
|
|
11
|
+
export declare function passkeysSupported(): boolean;
|
|
12
|
+
export declare function toBase64url(buf: ArrayBuffer | Uint8Array): string;
|
|
13
|
+
export declare function fromBase64url(s: string): Uint8Array<ArrayBuffer>;
|
|
14
|
+
/** Adds a passkey to the signed-in user's account. name labels it in their list; the default names the browser and system. */
|
|
15
|
+
export declare function registerPasskey(options?: PasskeyOptions & {
|
|
16
|
+
name?: string;
|
|
17
|
+
}): Promise<void>;
|
|
18
|
+
/** Signs in with a passkey. The browser offers the user's passkeys for this site; no email is needed. */
|
|
19
|
+
export declare function signInWithPasskey(options?: PasskeyOptions & {
|
|
20
|
+
remember?: boolean;
|
|
21
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Passkeys in the browser, for the accounts package's endpoints. Each
|
|
2
|
+
// helper asks the server for options, hands them to the browser's
|
|
3
|
+
// WebAuthn API, and posts the authenticator's answer back.
|
|
4
|
+
import { getBase } from './base.js';
|
|
5
|
+
/** A passkey ceremony that didn't finish. cancelled is true when the user closed the browser's prompt. */
|
|
6
|
+
export class PasskeyError extends Error {
|
|
7
|
+
cancelled;
|
|
8
|
+
constructor(message, cancelled = false) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = 'PasskeyError';
|
|
11
|
+
this.cancelled = cancelled;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** Whether this browser can use passkeys. */
|
|
15
|
+
export function passkeysSupported() {
|
|
16
|
+
return typeof window !== 'undefined' && typeof window.PublicKeyCredential === 'function' && !!navigator.credentials;
|
|
17
|
+
}
|
|
18
|
+
export function toBase64url(buf) {
|
|
19
|
+
const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
|
|
20
|
+
let s = '';
|
|
21
|
+
for (const b of bytes)
|
|
22
|
+
s += String.fromCharCode(b);
|
|
23
|
+
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
24
|
+
}
|
|
25
|
+
export function fromBase64url(s) {
|
|
26
|
+
const b = atob(s.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (s.length % 4)) % 4));
|
|
27
|
+
const out = new Uint8Array(new ArrayBuffer(b.length));
|
|
28
|
+
for (let i = 0; i < b.length; i++)
|
|
29
|
+
out[i] = b.charCodeAt(i);
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
const endpoint = (o, p) => getBase() + (o?.path ?? '/_tern/passkeys').replace(/\/$/, '') + p;
|
|
33
|
+
async function post(url, body) {
|
|
34
|
+
const res = await fetch(url, {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
37
|
+
credentials: 'same-origin',
|
|
38
|
+
body: JSON.stringify(body ?? {})
|
|
39
|
+
});
|
|
40
|
+
const data = (await res.json().catch(() => ({})));
|
|
41
|
+
if (!res.ok)
|
|
42
|
+
throw new PasskeyError(typeof data.message === 'string' ? data.message : `passkey request failed (${res.status})`);
|
|
43
|
+
return data;
|
|
44
|
+
}
|
|
45
|
+
function failed(e) {
|
|
46
|
+
if (e instanceof PasskeyError)
|
|
47
|
+
throw e;
|
|
48
|
+
const name = e?.name;
|
|
49
|
+
// NotAllowedError is the browser's answer for both "cancelled" and "timed out".
|
|
50
|
+
if (name === 'NotAllowedError' || name === 'AbortError')
|
|
51
|
+
throw new PasskeyError('cancelled', true);
|
|
52
|
+
throw new PasskeyError(e?.message ?? String(e));
|
|
53
|
+
}
|
|
54
|
+
/** Adds a passkey to the signed-in user's account. name labels it in their list; the default names the browser and system. */
|
|
55
|
+
export async function registerPasskey(options) {
|
|
56
|
+
try {
|
|
57
|
+
const o = (await post(endpoint(options, '/register/options')));
|
|
58
|
+
const publicKey = {
|
|
59
|
+
...o,
|
|
60
|
+
challenge: fromBase64url(o.challenge),
|
|
61
|
+
user: { ...o.user, id: fromBase64url(o.user.id) },
|
|
62
|
+
excludeCredentials: (o.excludeCredentials ?? []).map((c) => ({ type: c.type, id: fromBase64url(c.id) }))
|
|
63
|
+
};
|
|
64
|
+
const cred = (await navigator.credentials.create({ publicKey }));
|
|
65
|
+
if (!cred)
|
|
66
|
+
throw new PasskeyError('cancelled', true);
|
|
67
|
+
const r = cred.response;
|
|
68
|
+
await post(endpoint(options, '/register'), {
|
|
69
|
+
id: cred.id,
|
|
70
|
+
type: cred.type,
|
|
71
|
+
name: options?.name ?? '',
|
|
72
|
+
response: { clientDataJSON: toBase64url(r.clientDataJSON), attestationObject: toBase64url(r.attestationObject) }
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
catch (e) {
|
|
76
|
+
failed(e);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Signs in with a passkey. The browser offers the user's passkeys for this site; no email is needed. */
|
|
80
|
+
export async function signInWithPasskey(options) {
|
|
81
|
+
try {
|
|
82
|
+
const o = (await post(endpoint(options, '/signin/options')));
|
|
83
|
+
const publicKey = {
|
|
84
|
+
...o,
|
|
85
|
+
challenge: fromBase64url(o.challenge),
|
|
86
|
+
allowCredentials: []
|
|
87
|
+
};
|
|
88
|
+
const cred = (await navigator.credentials.get({ publicKey }));
|
|
89
|
+
if (!cred)
|
|
90
|
+
throw new PasskeyError('cancelled', true);
|
|
91
|
+
const r = cred.response;
|
|
92
|
+
await post(endpoint(options, '/signin'), {
|
|
93
|
+
id: cred.id,
|
|
94
|
+
type: cred.type,
|
|
95
|
+
remember: !!options?.remember,
|
|
96
|
+
response: {
|
|
97
|
+
clientDataJSON: toBase64url(r.clientDataJSON),
|
|
98
|
+
authenticatorData: toBase64url(r.authenticatorData),
|
|
99
|
+
signature: toBase64url(r.signature),
|
|
100
|
+
userHandle: r.userHandle ? toBase64url(r.userHandle) : ''
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
failed(e);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -56,7 +56,8 @@ export async function start(m) {
|
|
|
56
56
|
// Deferred values stream in as inline scripts calling __tern_resolve.
|
|
57
57
|
const w = window;
|
|
58
58
|
const scope = new DeferredScope();
|
|
59
|
-
|
|
59
|
+
if (payload.deferred)
|
|
60
|
+
payload.nodes = revive(payload.nodes, (id) => scope.promise(id));
|
|
60
61
|
for (const chunk of Object.values(w.__tern_d ?? {}))
|
|
61
62
|
scope.settle(chunk);
|
|
62
63
|
w.__tern_on = (chunk) => scope.settle(chunk);
|
package/dist/runtime/types.d.ts
CHANGED
|
@@ -49,6 +49,8 @@ export interface Payload {
|
|
|
49
49
|
data?: unknown;
|
|
50
50
|
form?: unknown;
|
|
51
51
|
hasForm?: boolean;
|
|
52
|
+
/** Whether the nodes have deferred values' placeholders to revive. */
|
|
53
|
+
deferred?: boolean;
|
|
52
54
|
/** What each node's load read, so the client knows when to rerun it. */
|
|
53
55
|
uses?: (NodeUses | null)[];
|
|
54
56
|
/** Nodes the server skipped because the client already has their data. */
|
|
@@ -17,7 +17,8 @@ export type FormErrors<Name extends string = string> = Partial<Record<Name, stri
|
|
|
17
17
|
export declare function parseFormTime(kind: 'date' | 'datetime', s: string): number | null;
|
|
18
18
|
/**
|
|
19
19
|
* Returns why a field's values are invalid, or "". values are its posted
|
|
20
|
-
* values; other reads another field's first value, for same=.
|
|
20
|
+
* values; other reads another field's first value, for same=. Messages
|
|
21
|
+
* are Tern's texts (tern.validate.*), translated through src/locales.
|
|
21
22
|
*/
|
|
22
23
|
export declare function checkField(f: FormField, values: string[], other?: (name: string) => string, labelOf?: (name: string) => string): string;
|
|
23
24
|
/**
|
package/dist/runtime/validate.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// kit.FormAction's validate tags. The checks and messages match
|
|
3
3
|
// internal/formrules in Go, which checks the same rules on the server;
|
|
4
4
|
// internal/formrules/testdata/cases.json keeps the two in step.
|
|
5
|
+
import { tb } from './i18n.svelte.js';
|
|
5
6
|
const emailRe = /^[^\s@<>()",;:]+@[^\s@<>()",;:]+\.[^\s@<>()",;:]+$/;
|
|
6
7
|
const intRe = /^[+-]?\d+$/;
|
|
7
8
|
const floatRe = /^[+-]?(\d+\.?\d*|\.\d+)$/;
|
|
@@ -33,45 +34,46 @@ export function parseFormTime(kind, s) {
|
|
|
33
34
|
}
|
|
34
35
|
return null;
|
|
35
36
|
}
|
|
36
|
-
function joinOr(list) {
|
|
37
|
-
return list.length === 1 ? list[0] : `${list.slice(0, -1).join(', ')} or ${list[list.length - 1]}`;
|
|
37
|
+
function joinOr(list, or) {
|
|
38
|
+
return list.length === 1 ? list[0] : `${list.slice(0, -1).join(', ')} ${or} ${list[list.length - 1]}`;
|
|
38
39
|
}
|
|
39
|
-
const bound = { min: 'at least', max: 'at most', len: 'exactly' };
|
|
40
40
|
/**
|
|
41
41
|
* Returns why a field's values are invalid, or "". values are its posted
|
|
42
|
-
* values; other reads another field's first value, for same=.
|
|
42
|
+
* values; other reads another field's first value, for same=. Messages
|
|
43
|
+
* are Tern's texts (tern.validate.*), translated through src/locales.
|
|
43
44
|
*/
|
|
44
45
|
export function checkField(f, values, other = () => '', labelOf = (n) => n) {
|
|
45
|
-
const msg = (
|
|
46
|
+
const msg = (key, params = {}) => f.message || tb(`tern.validate.${key}`, { ...params, field: f.label, fieldLower: f.label.toLowerCase() });
|
|
46
47
|
const list = values.filter((v) => v.trim() !== '');
|
|
47
48
|
const v = values[0] ?? '';
|
|
48
49
|
const empty = f.kind === 'strings' ? list.length === 0 : f.kind === 'bool' ? !truthy(v) : v.trim() === '';
|
|
49
50
|
if (empty) {
|
|
50
51
|
if (!f.required)
|
|
51
52
|
return '';
|
|
52
|
-
return msg(f.kind === 'bool' ?
|
|
53
|
+
return msg(f.kind === 'bool' ? 'checked' : 'required');
|
|
53
54
|
}
|
|
54
55
|
let num = 0;
|
|
55
56
|
let when = 0;
|
|
56
57
|
if (f.kind === 'int') {
|
|
57
58
|
if (!intRe.test(v.trim()))
|
|
58
|
-
return msg(
|
|
59
|
+
return msg('int');
|
|
59
60
|
num = Number(v.trim());
|
|
60
61
|
}
|
|
61
62
|
else if (f.kind === 'float') {
|
|
62
63
|
if (!floatRe.test(v.trim()))
|
|
63
|
-
return msg(
|
|
64
|
+
return msg('number');
|
|
64
65
|
num = Number(v.trim());
|
|
65
66
|
}
|
|
66
67
|
else if (f.kind === 'date' || f.kind === 'datetime') {
|
|
67
68
|
const t = parseFormTime(f.kind, v.trim());
|
|
68
69
|
if (t === null)
|
|
69
|
-
return msg(f.kind === 'date' ?
|
|
70
|
+
return msg(f.kind === 'date' ? 'date' : 'datetime');
|
|
70
71
|
when = t;
|
|
71
72
|
}
|
|
72
73
|
for (const r of f.rules ?? []) {
|
|
73
74
|
const arg = r.arg ?? '';
|
|
74
75
|
const n = Number(arg);
|
|
76
|
+
const bound = { count: arg, n };
|
|
75
77
|
switch (r.name) {
|
|
76
78
|
case 'min':
|
|
77
79
|
case 'max':
|
|
@@ -80,26 +82,26 @@ export function checkField(f, values, other = () => '', labelOf = (n) => n) {
|
|
|
80
82
|
const fails = (c) => (r.name === 'len' && c !== n) || (low && c < n) || (r.name === 'max' && c > n);
|
|
81
83
|
if (f.kind === 'string') {
|
|
82
84
|
if (fails([...v].length))
|
|
83
|
-
return msg(
|
|
85
|
+
return msg({ min: 'minLength', max: 'maxLength', len: 'length' }[r.name], bound);
|
|
84
86
|
}
|
|
85
87
|
else if (f.kind === 'int' || f.kind === 'float') {
|
|
86
88
|
if (fails(num))
|
|
87
|
-
return msg(
|
|
89
|
+
return msg({ min: 'min', max: 'max', len: 'exactly' }[r.name], bound);
|
|
88
90
|
}
|
|
89
91
|
else if (f.kind === 'date' || f.kind === 'datetime') {
|
|
90
92
|
const b = parseFormTime(f.kind, arg) ?? 0;
|
|
91
93
|
if ((low && when < b) || (r.name === 'max' && when > b))
|
|
92
|
-
return msg(
|
|
94
|
+
return msg(low ? 'after' : 'before', { date: arg });
|
|
93
95
|
}
|
|
94
96
|
else if (f.kind === 'strings') {
|
|
95
97
|
if (fails(list.length))
|
|
96
|
-
return msg(
|
|
98
|
+
return msg({ min: 'minItems', max: 'maxItems', len: 'items' }[r.name], bound);
|
|
97
99
|
}
|
|
98
100
|
break;
|
|
99
101
|
}
|
|
100
102
|
case 'email':
|
|
101
103
|
if (!emailRe.test(v))
|
|
102
|
-
return msg(
|
|
104
|
+
return msg('email');
|
|
103
105
|
break;
|
|
104
106
|
case 'url': {
|
|
105
107
|
let ok = false;
|
|
@@ -111,7 +113,7 @@ export function checkField(f, values, other = () => '', labelOf = (n) => n) {
|
|
|
111
113
|
ok = false;
|
|
112
114
|
}
|
|
113
115
|
if (!ok)
|
|
114
|
-
return msg(
|
|
116
|
+
return msg('url');
|
|
115
117
|
break;
|
|
116
118
|
}
|
|
117
119
|
case 'pattern': {
|
|
@@ -120,19 +122,19 @@ export function checkField(f, values, other = () => '', labelOf = (n) => n) {
|
|
|
120
122
|
patterns.set(arg, (re = new RegExp(`^(?:${arg})$`, 'u')));
|
|
121
123
|
for (const s of f.kind === 'strings' ? list : [v])
|
|
122
124
|
if (!re.test(s))
|
|
123
|
-
return msg(
|
|
125
|
+
return msg('pattern');
|
|
124
126
|
break;
|
|
125
127
|
}
|
|
126
128
|
case 'oneof': {
|
|
127
129
|
const opts = arg.split('|');
|
|
128
130
|
for (const s of f.kind === 'strings' ? list : [v])
|
|
129
131
|
if (!opts.includes(s))
|
|
130
|
-
return msg(
|
|
132
|
+
return msg('oneof', { options: joinOr(opts, tb('tern.validate.or')) });
|
|
131
133
|
break;
|
|
132
134
|
}
|
|
133
135
|
case 'same':
|
|
134
136
|
if (other(arg) !== v)
|
|
135
|
-
return msg(
|
|
137
|
+
return msg('same', { otherLower: labelOf(arg).toLowerCase() });
|
|
136
138
|
break;
|
|
137
139
|
}
|
|
138
140
|
}
|
package/dist/server/index.d.ts
CHANGED
|
@@ -35,6 +35,8 @@ export interface RenderResult {
|
|
|
35
35
|
* same payload the client receives, so the server and the client render
|
|
36
36
|
* exactly the same tree and hydration matches.
|
|
37
37
|
*/
|
|
38
|
-
export declare function createRenderer(manifest: ServerManifest): (payload: string, href: string) => string
|
|
38
|
+
export declare function createRenderer(manifest: ServerManifest): ((payload: string, href: string) => string) & {
|
|
39
|
+
raw: (payload: string, href: string) => RenderResult;
|
|
40
|
+
};
|
|
39
41
|
export { createEmailRenderer } from './email.js';
|
|
40
42
|
export type { EmailModule, EmailResult } from './email.js';
|
package/dist/server/index.js
CHANGED
|
@@ -16,15 +16,18 @@ export function createRenderer(manifest) {
|
|
|
16
16
|
const layout = (i) => manifest.layouts[i]?.component ?? Passthrough;
|
|
17
17
|
configureI18n(manifest.i18n ?? null);
|
|
18
18
|
setBase(manifest.base ?? '');
|
|
19
|
-
|
|
19
|
+
// raw returns the result as an object, for a native renderer to read
|
|
20
|
+
// without encoding it as JSON first.
|
|
21
|
+
const raw = (payloadJSON, href) => {
|
|
20
22
|
const p = JSON.parse(payloadJSON);
|
|
21
23
|
// Deferred values are still loading: render their pending state.
|
|
22
|
-
|
|
24
|
+
if (p.deferred)
|
|
25
|
+
p.nodes = revive(p.nodes, () => new Promise(() => { }));
|
|
23
26
|
const what = plan(manifest, p);
|
|
24
27
|
let components;
|
|
25
28
|
switch (what.kind) {
|
|
26
29
|
case 'not-a-page':
|
|
27
|
-
return
|
|
30
|
+
return { skip: true };
|
|
28
31
|
case 'page':
|
|
29
32
|
components = [...what.layouts.map(layout), manifest.routes[what.route].page];
|
|
30
33
|
break;
|
|
@@ -40,7 +43,8 @@ export function createRenderer(manifest) {
|
|
|
40
43
|
applyPayloadToPage(p, new URL(href), {}, what.data);
|
|
41
44
|
const form = p.hasForm ? (p.form ?? null) : null;
|
|
42
45
|
const out = render(Root, { props: { components, data: what.data, form } });
|
|
43
|
-
return
|
|
46
|
+
return { head: out.head, body: out.body };
|
|
44
47
|
};
|
|
48
|
+
return Object.assign((payloadJSON, href) => JSON.stringify(raw(payloadJSON, href)), { raw });
|
|
45
49
|
}
|
|
46
50
|
export { createEmailRenderer } from './email.js';
|