@ternkit/tern 1.9.0 → 1.11.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 +1 -1
- package/dist/index.js +1 -1
- package/dist/runtime/builtin.js +120 -0
- package/dist/runtime/router.svelte.js +35 -0
- package/dist/runtime/types.d.ts +2 -0
- package/dist/ui/AcceptInvitation.svelte +74 -0
- package/dist/ui/AcceptInvitation.svelte.d.ts +34 -0
- package/dist/ui/ActivityLog.svelte +67 -0
- package/dist/ui/ActivityLog.svelte.d.ts +27 -0
- package/dist/ui/AuthCard.svelte +3 -2
- package/dist/ui/BillingSettings.svelte +120 -0
- package/dist/ui/BillingSettings.svelte.d.ts +27 -0
- package/dist/ui/NewTeam.svelte +31 -0
- package/dist/ui/NewTeam.svelte.d.ts +15 -0
- package/dist/ui/PricingTable.svelte +217 -0
- package/dist/ui/PricingTable.svelte.d.ts +23 -0
- package/dist/ui/TeamInvitations.svelte +97 -0
- package/dist/ui/TeamInvitations.svelte.d.ts +32 -0
- package/dist/ui/TeamMembers.svelte +100 -0
- package/dist/ui/TeamMembers.svelte.d.ts +33 -0
- package/dist/ui/TeamSettings.svelte +88 -0
- package/dist/ui/TeamSettings.svelte.d.ts +22 -0
- package/dist/ui/TeamSwitcher.svelte +58 -0
- package/dist/ui/TeamSwitcher.svelte.d.ts +23 -0
- package/dist/ui/billing.d.ts +31 -0
- package/dist/ui/billing.js +33 -0
- package/dist/ui/index.d.ts +9 -0
- package/dist/ui/index.js +9 -0
- package/dist/ui/teams.d.ts +20 -0
- package/dist/ui/teams.js +40 -0
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -18,4 +18,4 @@ export { default as Image } from './image/Image.svelte';
|
|
|
18
18
|
export { t, i18n, setLocale } from './runtime/i18n.svelte.js';
|
|
19
19
|
export { passkeysSupported, registerPasskey, signInWithPasskey, PasskeyError } from './runtime/passkeys.js';
|
|
20
20
|
/** The version of the Tern client runtime. */
|
|
21
|
-
export const VERSION = '1.
|
|
21
|
+
export const VERSION = '1.11.0';
|
package/dist/runtime/builtin.js
CHANGED
|
@@ -8,14 +8,23 @@ export const builtin = {
|
|
|
8
8
|
"tern.accounts.changed.button": "",
|
|
9
9
|
"tern.accounts.changed.subject": "Your email address was changed",
|
|
10
10
|
"tern.accounts.changed.text": "Hello {name},\n\nYour account’s email address was changed, and this address no longer signs in. If you didn’t do this, contact us right away.",
|
|
11
|
+
"tern.accounts.error.alreadyMember": "They’re already in the team.",
|
|
11
12
|
"tern.accounts.error.credentials": "Wrong email or password.",
|
|
12
13
|
"tern.accounts.error.emailTaken": "An account with this email address already exists.",
|
|
13
14
|
"tern.accounts.error.invalidCode": "That code didn’t work. Check the time on your phone, or use a recovery code.",
|
|
14
15
|
"tern.accounts.error.invalidToken": "This link is invalid or has expired.",
|
|
16
|
+
"tern.accounts.error.lastOwner": "A team needs an owner. Make someone else an owner first.",
|
|
15
17
|
"tern.accounts.error.lastSignIn": "Add a password or a passkey first: this is the only way to sign in to your account.",
|
|
18
|
+
"tern.accounts.error.notAllowed": "Your role in this team doesn’t allow this.",
|
|
19
|
+
"tern.accounts.error.notMember": "They aren’t in this team.",
|
|
16
20
|
"tern.accounts.error.passkey": "That passkey didn’t work.",
|
|
17
21
|
"tern.accounts.error.tooMany": "Too many attempts. Try again in a few minutes.",
|
|
22
|
+
"tern.accounts.error.unknownRole": "Choose one of the team’s roles.",
|
|
23
|
+
"tern.accounts.error.wrongInvitee": "This invitation was sent to another email address. Sign in with that one to accept it.",
|
|
18
24
|
"tern.accounts.error.wrongPassword": "That isn’t your current password.",
|
|
25
|
+
"tern.accounts.invite.button": "Accept the invitation",
|
|
26
|
+
"tern.accounts.invite.subject": "{inviter} invited you to {team}",
|
|
27
|
+
"tern.accounts.invite.text": "Hello,\n\n{inviter} invited you to join {team} as {role}. Accept the invitation within a week here:\n{link}\n\nIf you weren’t expecting it, you can ignore this email.",
|
|
19
28
|
"tern.accounts.new_device.subject": "A new sign-in to your account",
|
|
20
29
|
"tern.accounts.new_device.text": "Hello {name},\n\nYour account was just signed in to from a device it hadn’t been used on before: {device}, from {ip}, on {time}.\n\nIf this was you, there’s nothing to do. If it wasn’t, change your password now and sign that device out in your account’s security settings.",
|
|
21
30
|
"tern.accounts.reset.button": "Choose a new password",
|
|
@@ -24,9 +33,71 @@ export const builtin = {
|
|
|
24
33
|
"tern.accounts.verify.button": "Confirm my email address",
|
|
25
34
|
"tern.accounts.verify.subject": "Confirm your email address",
|
|
26
35
|
"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.",
|
|
36
|
+
"tern.billing.limit": "The {plan} plan allows {max} {limit}. Upgrade to add more.",
|
|
37
|
+
"tern.ui.activity.description": "Sign-ins and changes to the account. If something wasn’t you, change your password.",
|
|
38
|
+
"tern.ui.activity.empty": "Nothing yet.",
|
|
39
|
+
"tern.ui.activity.identity.linked": "Linked {target}",
|
|
40
|
+
"tern.ui.activity.identity.unlinked": "Unlinked {target}",
|
|
41
|
+
"tern.ui.activity.member.invited": "Invited {target} as {role}",
|
|
42
|
+
"tern.ui.activity.member.joined": "{target} joined as {role}",
|
|
43
|
+
"tern.ui.activity.member.left": "{target} left the team",
|
|
44
|
+
"tern.ui.activity.member.removed": "Removed {target}",
|
|
45
|
+
"tern.ui.activity.member.role": "Changed {target} from {from} to {to}",
|
|
46
|
+
"tern.ui.activity.member.uninvited": "Cancelled the invitation for {target}",
|
|
47
|
+
"tern.ui.activity.mine.member.joined": "Joined {team} as {role}",
|
|
48
|
+
"tern.ui.activity.mine.member.left": "Left {team}",
|
|
49
|
+
"tern.ui.activity.more": "Show older",
|
|
50
|
+
"tern.ui.activity.passkey.added": "Added the passkey {target}",
|
|
51
|
+
"tern.ui.activity.passkey.removed": "Removed a passkey",
|
|
52
|
+
"tern.ui.activity.session.others_signed_out": "Signed out all other devices",
|
|
53
|
+
"tern.ui.activity.session.revoked": "Signed out a device",
|
|
54
|
+
"tern.ui.activity.team.created": "Created the team {target}",
|
|
55
|
+
"tern.ui.activity.team.deleted": "Deleted the team {target}",
|
|
56
|
+
"tern.ui.activity.team.renamed": "Renamed the team to {target}",
|
|
57
|
+
"tern.ui.activity.title": "Recent activity",
|
|
58
|
+
"tern.ui.activity.user.deleted": "Deleted the account",
|
|
59
|
+
"tern.ui.activity.user.email_changed": "Changed the email address to {target}",
|
|
60
|
+
"tern.ui.activity.user.email_verified": "Confirmed {target}",
|
|
61
|
+
"tern.ui.activity.user.password_changed": "Changed the password",
|
|
62
|
+
"tern.ui.activity.user.password_reset": "Reset the password",
|
|
63
|
+
"tern.ui.activity.user.sign_in_failed": "Failed sign-in attempt",
|
|
64
|
+
"tern.ui.activity.user.signed_in": "Signed in",
|
|
65
|
+
"tern.ui.activity.user.signed_out": "Signed out",
|
|
66
|
+
"tern.ui.activity.user.signed_up": "Created the account",
|
|
67
|
+
"tern.ui.activity.user.two_step_off": "Turned off two-step sign-in",
|
|
68
|
+
"tern.ui.activity.user.two_step_on": "Turned on two-step sign-in",
|
|
27
69
|
"tern.ui.alertDialog.cancel": "Cancel",
|
|
28
70
|
"tern.ui.alertDialog.confirm": "Continue",
|
|
29
71
|
"tern.ui.barChart.total": "Total",
|
|
72
|
+
"tern.ui.billing.cancelAnytime": "Cancel anytime.",
|
|
73
|
+
"tern.ui.billing.cancels": "The plan ends on {date}, and won’t renew.",
|
|
74
|
+
"tern.ui.billing.changePlan": "Change plan",
|
|
75
|
+
"tern.ui.billing.choose": "Choose {plan}",
|
|
76
|
+
"tern.ui.billing.current": "Your plan",
|
|
77
|
+
"tern.ui.billing.interval": "Billing period",
|
|
78
|
+
"tern.ui.billing.manage": "Manage billing",
|
|
79
|
+
"tern.ui.billing.monthly": "Monthly",
|
|
80
|
+
"tern.ui.billing.noCard": "No card needed.",
|
|
81
|
+
"tern.ui.billing.pastDue": "The last payment failed. Update the payment method to keep the plan.",
|
|
82
|
+
"tern.ui.billing.perMonth": "per month",
|
|
83
|
+
"tern.ui.billing.perSeat": "per member",
|
|
84
|
+
"tern.ui.billing.perYear": "per year",
|
|
85
|
+
"tern.ui.billing.popular": "Popular",
|
|
86
|
+
"tern.ui.billing.renews": "Renews on {date}.",
|
|
87
|
+
"tern.ui.billing.seats": "{n, plural, one {# member} other {# members}}",
|
|
88
|
+
"tern.ui.billing.startFree": "Start for free",
|
|
89
|
+
"tern.ui.billing.startTrial": "Start free trial",
|
|
90
|
+
"tern.ui.billing.status.canceling": "Ending",
|
|
91
|
+
"tern.ui.billing.status.pastDue": "Payment failed",
|
|
92
|
+
"tern.ui.billing.status.trialing": "Trial",
|
|
93
|
+
"tern.ui.billing.title": "Plan and billing",
|
|
94
|
+
"tern.ui.billing.trial": "{days}-day free trial, no charge until it ends.",
|
|
95
|
+
"tern.ui.billing.trialEnds": "Your trial ends on {date}. Add a payment method before then to keep the plan.",
|
|
96
|
+
"tern.ui.billing.unlimitedUse": "{used}, no limit",
|
|
97
|
+
"tern.ui.billing.upgrade": "Upgrade",
|
|
98
|
+
"tern.ui.billing.used": "{used} of {max}",
|
|
99
|
+
"tern.ui.billing.yearly": "Yearly",
|
|
100
|
+
"tern.ui.billing.yearlySave": "Yearly · save {percent}%",
|
|
30
101
|
"tern.ui.breadcrumb.label": "Breadcrumb",
|
|
31
102
|
"tern.ui.calendar.nextMonth": "Next month",
|
|
32
103
|
"tern.ui.calendar.previousMonth": "Previous month",
|
|
@@ -131,6 +202,55 @@ export const builtin = {
|
|
|
131
202
|
"tern.ui.signUp.title": "Create an account",
|
|
132
203
|
"tern.ui.spinner.label": "Loading",
|
|
133
204
|
"tern.ui.table.empty": "Nothing to show",
|
|
205
|
+
"tern.ui.team.accept.description": "{inviter} invited you to join {team} as {role}.",
|
|
206
|
+
"tern.ui.team.accept.invalid": "It has expired, was cancelled, or was already accepted. Ask for a new one.",
|
|
207
|
+
"tern.ui.team.accept.invalidTitle": "This invitation doesn’t work",
|
|
208
|
+
"tern.ui.team.accept.signIn": "I already have an account",
|
|
209
|
+
"tern.ui.team.accept.signOut": "Sign out",
|
|
210
|
+
"tern.ui.team.accept.signUp": "Create an account",
|
|
211
|
+
"tern.ui.team.accept.signedOut": "Sign in or create an account with {email} to accept it.",
|
|
212
|
+
"tern.ui.team.accept.submit": "Join {team}",
|
|
213
|
+
"tern.ui.team.accept.title": "Join {team}",
|
|
214
|
+
"tern.ui.team.accept.wrongAccount": "This invitation is for {email}, and you’re signed in as {current}. Sign out, then sign in with {email}.",
|
|
215
|
+
"tern.ui.team.invite.cancel": "Cancel",
|
|
216
|
+
"tern.ui.team.invite.cancelFor": "Cancel the invitation for {email}",
|
|
217
|
+
"tern.ui.team.invite.description": "They get an email with a link that works for a week.",
|
|
218
|
+
"tern.ui.team.invite.email": "Email address",
|
|
219
|
+
"tern.ui.team.invite.expires": "expires {when}",
|
|
220
|
+
"tern.ui.team.invite.pending": "Pending invitations",
|
|
221
|
+
"tern.ui.team.invite.role": "Role",
|
|
222
|
+
"tern.ui.team.invite.send": "Send invitation",
|
|
223
|
+
"tern.ui.team.invite.sent": "Invitation sent to {email}.",
|
|
224
|
+
"tern.ui.team.invite.title": "Invite people",
|
|
225
|
+
"tern.ui.team.joined": "Joined {when}",
|
|
226
|
+
"tern.ui.team.leave": "Leave team",
|
|
227
|
+
"tern.ui.team.members.description": "Everyone in this team, and what they can do.",
|
|
228
|
+
"tern.ui.team.members.title": "Members",
|
|
229
|
+
"tern.ui.team.new.cancel": "Not now",
|
|
230
|
+
"tern.ui.team.new.description": "A team shares its data. You can invite people next.",
|
|
231
|
+
"tern.ui.team.new.name": "Team name",
|
|
232
|
+
"tern.ui.team.new.submit": "Create team",
|
|
233
|
+
"tern.ui.team.new.title": "Create a team",
|
|
234
|
+
"tern.ui.team.remove": "Remove",
|
|
235
|
+
"tern.ui.team.removeName": "Remove {name}",
|
|
236
|
+
"tern.ui.team.role.admin": "Admin",
|
|
237
|
+
"tern.ui.team.role.member": "Member",
|
|
238
|
+
"tern.ui.team.role.owner": "Owner",
|
|
239
|
+
"tern.ui.team.roleOf": "Role of {name}",
|
|
240
|
+
"tern.ui.team.save": "Save",
|
|
241
|
+
"tern.ui.team.settings.delete": "Delete this team",
|
|
242
|
+
"tern.ui.team.settings.deleteButton": "Delete team",
|
|
243
|
+
"tern.ui.team.settings.deleteConfirm": "Delete {team}?",
|
|
244
|
+
"tern.ui.team.settings.deleteHelp": "Its data and members’ access are removed for good.",
|
|
245
|
+
"tern.ui.team.settings.deleteWarning": "Everything in this team is deleted, and its members lose access. This can’t be undone.",
|
|
246
|
+
"tern.ui.team.settings.name": "Team name",
|
|
247
|
+
"tern.ui.team.settings.rename": "Save name",
|
|
248
|
+
"tern.ui.team.settings.title": "Team",
|
|
249
|
+
"tern.ui.team.settings.typeName": "Type {team} to confirm",
|
|
250
|
+
"tern.ui.team.switcher.heading": "Teams",
|
|
251
|
+
"tern.ui.team.switcher.new": "New team",
|
|
252
|
+
"tern.ui.team.switcher.none": "No team",
|
|
253
|
+
"tern.ui.team.you": "You",
|
|
134
254
|
"tern.ui.toaster.dismiss": "Dismiss",
|
|
135
255
|
"tern.ui.toaster.label": "Notifications",
|
|
136
256
|
"tern.ui.twoStep.cantScan": "Can’t scan it?",
|
|
@@ -84,6 +84,8 @@ export async function start(m) {
|
|
|
84
84
|
el.remove();
|
|
85
85
|
runAfter({ from: null, to: current(), type: 'enter' });
|
|
86
86
|
registerServiceWorker();
|
|
87
|
+
if (payload.errors)
|
|
88
|
+
reportErrors();
|
|
87
89
|
const saved = fresh ? undefined : scrollPositions[historyIndex];
|
|
88
90
|
if (saved) {
|
|
89
91
|
jump(saved.x, saved.y);
|
|
@@ -93,6 +95,39 @@ export async function start(m) {
|
|
|
93
95
|
}
|
|
94
96
|
listen();
|
|
95
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Sends the page's uncaught errors and unhandled rejections to the server's
|
|
100
|
+
* reporter: at most ten a page, each message once.
|
|
101
|
+
*/
|
|
102
|
+
function reportErrors() {
|
|
103
|
+
const sent = new Set();
|
|
104
|
+
const url = `${manifest.base ?? ''}/__tern/errors`;
|
|
105
|
+
const send = (name, message, stack) => {
|
|
106
|
+
if (!message || sent.size >= 10 || sent.has(message))
|
|
107
|
+
return;
|
|
108
|
+
sent.add(message);
|
|
109
|
+
const body = JSON.stringify({ name, message, stack, url: location.href, route: page.route.id ?? '' });
|
|
110
|
+
try {
|
|
111
|
+
if (navigator.sendBeacon?.(url, new Blob([body], { type: 'application/json' })))
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// fall through to fetch
|
|
116
|
+
}
|
|
117
|
+
void fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, keepalive: true }).catch(() => { });
|
|
118
|
+
};
|
|
119
|
+
addEventListener('error', (e) => {
|
|
120
|
+
const err = e.error;
|
|
121
|
+
send(err?.name ?? 'Error', err?.message ?? e.message, err?.stack ?? `at ${e.filename}:${e.lineno}:${e.colno}`);
|
|
122
|
+
});
|
|
123
|
+
addEventListener('unhandledrejection', (e) => {
|
|
124
|
+
const r = e.reason;
|
|
125
|
+
if (r instanceof Error)
|
|
126
|
+
send(r.name, r.message, r.stack ?? '');
|
|
127
|
+
else
|
|
128
|
+
send('UnhandledRejection', String(r), '');
|
|
129
|
+
});
|
|
130
|
+
}
|
|
96
131
|
function registerServiceWorker() {
|
|
97
132
|
// Not in development: a caching worker would serve stale modules.
|
|
98
133
|
if (!manifest.serviceWorker || import.meta.env?.DEV || !('serviceWorker' in navigator))
|
package/dist/runtime/types.d.ts
CHANGED
|
@@ -51,6 +51,8 @@ export interface Payload {
|
|
|
51
51
|
hasForm?: boolean;
|
|
52
52
|
/** Whether the nodes have deferred values' placeholders to revive. */
|
|
53
53
|
deferred?: boolean;
|
|
54
|
+
/** The server takes the page's uncaught errors (a reporter is set). */
|
|
55
|
+
errors?: boolean;
|
|
54
56
|
/** What each node's load read, so the client knows when to rerun it. */
|
|
55
57
|
uses?: (NodeUses | null)[];
|
|
56
58
|
/** Nodes the server skipped because the client already has their data. */
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { tb } from '../runtime/i18n.svelte.js';
|
|
3
|
+
import Alert from './Alert.svelte';
|
|
4
|
+
import AuthCard from './AuthCard.svelte';
|
|
5
|
+
import Button from './Button.svelte';
|
|
6
|
+
import Form from './Form.svelte';
|
|
7
|
+
|
|
8
|
+
type Invitation = { teamName: string; email: string; role: string; invitedBy: string; expiresAt: string };
|
|
9
|
+
type Props = {
|
|
10
|
+
title?: string;
|
|
11
|
+
level?: 1 | 2 | 3;
|
|
12
|
+
/** From acc.InvitationFor, or null when the link is invalid or expired. */
|
|
13
|
+
invitation: Invitation | null;
|
|
14
|
+
/** The link's token, posted to the action. */
|
|
15
|
+
token: string;
|
|
16
|
+
/** The signed-in user's email, or null when signed out. */
|
|
17
|
+
email: string | null;
|
|
18
|
+
/** The action that calls acc.AcceptInvitation with the posted token. */
|
|
19
|
+
action?: string;
|
|
20
|
+
/** Sign-in and sign-up pages, for signed-out visitors: ?next= brings them back here. */
|
|
21
|
+
signInHref?: string;
|
|
22
|
+
signUpHref?: string;
|
|
23
|
+
/** An action that signs out, offered when the user is signed in with another address. */
|
|
24
|
+
signOutAction?: string;
|
|
25
|
+
/** Posted to signOutAction as next: this page's address, to come back and sign in with the invited one. */
|
|
26
|
+
signOutNext?: string;
|
|
27
|
+
/** The role's label, when it's one of the app's own. */
|
|
28
|
+
roleLabel?: string;
|
|
29
|
+
/** The page's form prop, for the action's errors. */
|
|
30
|
+
form?: unknown;
|
|
31
|
+
class?: string;
|
|
32
|
+
};
|
|
33
|
+
let { title, level = 1, invitation, token, email, action = '?/accept', signInHref, signUpHref, signOutAction, signOutNext, roleLabel, form, class: klass = '' }: Props = $props();
|
|
34
|
+
|
|
35
|
+
let submitting = $state(false);
|
|
36
|
+
const role = $derived(roleLabel ?? (invitation ? tb(`tern.ui.team.role.${invitation.role}`) : ''));
|
|
37
|
+
const heading = $derived(title ?? (invitation ? tb('tern.ui.team.accept.title', { team: invitation.teamName }) : tb('tern.ui.team.accept.invalidTitle')));
|
|
38
|
+
const wrongAccount = $derived(!!invitation && !!email && email.toLowerCase() !== invitation.email.toLowerCase());
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<AuthCard
|
|
42
|
+
id="tern-accept-title"
|
|
43
|
+
title={heading}
|
|
44
|
+
description={invitation ? tb('tern.ui.team.accept.description', { inviter: invitation.invitedBy || invitation.teamName, team: invitation.teamName, role: role.toLowerCase() }) : tb('tern.ui.team.accept.invalid')}
|
|
45
|
+
{level}
|
|
46
|
+
class={klass}
|
|
47
|
+
>
|
|
48
|
+
{#if invitation}
|
|
49
|
+
{#if !email}
|
|
50
|
+
<p class="tern-auth-note">{tb('tern.ui.team.accept.signedOut', { email: invitation.email })}</p>
|
|
51
|
+
{#if signUpHref}<div class="tern-accept-cta"><Button href={signUpHref}>{tb('tern.ui.team.accept.signUp')}</Button></div>{/if}
|
|
52
|
+
{#if signInHref}<p class="tern-auth-note"><a href={signInHref}>{tb('tern.ui.team.accept.signIn')}</a></p>{/if}
|
|
53
|
+
{:else if wrongAccount}
|
|
54
|
+
<Alert tone="warning">{tb('tern.ui.team.accept.wrongAccount', { email: invitation.email, current: email })}</Alert>
|
|
55
|
+
{#if signOutAction}
|
|
56
|
+
<form method="POST" action={signOutAction} class="tern-accept-cta">
|
|
57
|
+
{#if signOutNext}<input type="hidden" name="next" value={signOutNext} />{/if}
|
|
58
|
+
<Button type="submit" variant="secondary">{tb('tern.ui.team.accept.signOut')}</Button>
|
|
59
|
+
</form>
|
|
60
|
+
{/if}
|
|
61
|
+
{:else}
|
|
62
|
+
<Form {action} {form} bind:submitting reset={false}>
|
|
63
|
+
<input type="hidden" name="token" value={token} />
|
|
64
|
+
<Button type="submit" loading={submitting}>{tb('tern.ui.team.accept.submit', { team: invitation.teamName })}</Button>
|
|
65
|
+
</Form>
|
|
66
|
+
{/if}
|
|
67
|
+
{/if}
|
|
68
|
+
</AuthCard>
|
|
69
|
+
|
|
70
|
+
<style>
|
|
71
|
+
.tern-accept-cta {
|
|
72
|
+
display: grid;
|
|
73
|
+
}
|
|
74
|
+
</style>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
type Invitation = {
|
|
2
|
+
teamName: string;
|
|
3
|
+
email: string;
|
|
4
|
+
role: string;
|
|
5
|
+
invitedBy: string;
|
|
6
|
+
expiresAt: string;
|
|
7
|
+
};
|
|
8
|
+
type Props = {
|
|
9
|
+
title?: string;
|
|
10
|
+
level?: 1 | 2 | 3;
|
|
11
|
+
/** From acc.InvitationFor, or null when the link is invalid or expired. */
|
|
12
|
+
invitation: Invitation | null;
|
|
13
|
+
/** The link's token, posted to the action. */
|
|
14
|
+
token: string;
|
|
15
|
+
/** The signed-in user's email, or null when signed out. */
|
|
16
|
+
email: string | null;
|
|
17
|
+
/** The action that calls acc.AcceptInvitation with the posted token. */
|
|
18
|
+
action?: string;
|
|
19
|
+
/** Sign-in and sign-up pages, for signed-out visitors: ?next= brings them back here. */
|
|
20
|
+
signInHref?: string;
|
|
21
|
+
signUpHref?: string;
|
|
22
|
+
/** An action that signs out, offered when the user is signed in with another address. */
|
|
23
|
+
signOutAction?: string;
|
|
24
|
+
/** Posted to signOutAction as next: this page's address, to come back and sign in with the invited one. */
|
|
25
|
+
signOutNext?: string;
|
|
26
|
+
/** The role's label, when it's one of the app's own. */
|
|
27
|
+
roleLabel?: string;
|
|
28
|
+
/** The page's form prop, for the action's errors. */
|
|
29
|
+
form?: unknown;
|
|
30
|
+
class?: string;
|
|
31
|
+
};
|
|
32
|
+
declare const AcceptInvitation: import("svelte").Component<Props, {}, "">;
|
|
33
|
+
type AcceptInvitation = ReturnType<typeof AcceptInvitation>;
|
|
34
|
+
export default AcceptInvitation;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { tb } from '../runtime/i18n.svelte.js';
|
|
3
|
+
import AccountSection from './AccountSection.svelte';
|
|
4
|
+
import Button from './Button.svelte';
|
|
5
|
+
import { when } from './accountDates.js';
|
|
6
|
+
|
|
7
|
+
type Entry = { id: number; at: string; actor: string; action: string; target: string; ip: string; device: string; data?: Record<string, string> | null };
|
|
8
|
+
type Props = {
|
|
9
|
+
title?: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
level?: 2 | 3 | 4;
|
|
12
|
+
/** From acc.Activity or acc.TeamActivity: the newest first. */
|
|
13
|
+
entries: Entry[];
|
|
14
|
+
/** Shows who did each thing: for a team's log. */
|
|
15
|
+
showActor?: boolean;
|
|
16
|
+
/** A link to the next page, such as `?before=${entries.at(-1).id}`; shown when there may be more. */
|
|
17
|
+
moreHref?: string;
|
|
18
|
+
/** Words for actions of the app's own, by action: { "project.deleted": "Deleted the project {target}" }. */
|
|
19
|
+
labels?: Record<string, string>;
|
|
20
|
+
class?: string;
|
|
21
|
+
};
|
|
22
|
+
let { title, description, level = 2, entries, showActor = false, moreHref, labels = {}, class: klass = '' }: Props = $props();
|
|
23
|
+
|
|
24
|
+
// The built-in actions are worded in tern.ui.activity.<action>; others
|
|
25
|
+
// come from labels, or show as they are.
|
|
26
|
+
function describe(e: Entry): string {
|
|
27
|
+
const params = { target: e.target, ...e.data };
|
|
28
|
+
const own = labels[e.action];
|
|
29
|
+
if (own) return own.replace(/\{(\w+)\}/g, (m, k: string) => (k in params ? String((params as Record<string, string>)[k]) : m));
|
|
30
|
+
// The user's own log words some actions as theirs: "Joined Acme".
|
|
31
|
+
if (!showActor) {
|
|
32
|
+
const mine = `tern.ui.activity.mine.${e.action}`;
|
|
33
|
+
const text = tb(mine, params);
|
|
34
|
+
if (text !== mine) return text;
|
|
35
|
+
}
|
|
36
|
+
const key = `tern.ui.activity.${e.action}`;
|
|
37
|
+
const text = tb(key, params);
|
|
38
|
+
return text === key ? e.action : text;
|
|
39
|
+
}
|
|
40
|
+
const failed = (e: Entry) => e.action === 'user.sign_in_failed';
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
<AccountSection id="tern-activity-title" title={title ?? tb('tern.ui.activity.title')} description={description ?? tb('tern.ui.activity.description')} {level} class={klass}>
|
|
44
|
+
{#if entries.length}
|
|
45
|
+
<ul>
|
|
46
|
+
{#each entries as e (e.id)}
|
|
47
|
+
<li class:tern-activity-failed={failed(e)}>
|
|
48
|
+
<div class="tern-account-text">
|
|
49
|
+
<span class="tern-account-name">{describe(e)}</span>
|
|
50
|
+
<span class="tern-account-meta">
|
|
51
|
+
{#if showActor && e.actor}{e.actor}{' · '}{/if}<time datetime={e.at}>{when(e.at)}</time>{e.device ? ` · ${e.device}` : ''}{e.ip ? ` · ${e.ip}` : ''}
|
|
52
|
+
</span>
|
|
53
|
+
</div>
|
|
54
|
+
</li>
|
|
55
|
+
{/each}
|
|
56
|
+
</ul>
|
|
57
|
+
{#if moreHref}<div><Button href={moreHref} variant="secondary" size="sm">{tb('tern.ui.activity.more')}</Button></div>{/if}
|
|
58
|
+
{:else}
|
|
59
|
+
<p class="tern-account-empty">{tb('tern.ui.activity.empty')}</p>
|
|
60
|
+
{/if}
|
|
61
|
+
</AccountSection>
|
|
62
|
+
|
|
63
|
+
<style>
|
|
64
|
+
.tern-activity-failed .tern-account-name {
|
|
65
|
+
color: var(--tern-danger);
|
|
66
|
+
}
|
|
67
|
+
</style>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
type Entry = {
|
|
2
|
+
id: number;
|
|
3
|
+
at: string;
|
|
4
|
+
actor: string;
|
|
5
|
+
action: string;
|
|
6
|
+
target: string;
|
|
7
|
+
ip: string;
|
|
8
|
+
device: string;
|
|
9
|
+
data?: Record<string, string> | null;
|
|
10
|
+
};
|
|
11
|
+
type Props = {
|
|
12
|
+
title?: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
level?: 2 | 3 | 4;
|
|
15
|
+
/** From acc.Activity or acc.TeamActivity: the newest first. */
|
|
16
|
+
entries: Entry[];
|
|
17
|
+
/** Shows who did each thing: for a team's log. */
|
|
18
|
+
showActor?: boolean;
|
|
19
|
+
/** A link to the next page, such as `?before=${entries.at(-1).id}`; shown when there may be more. */
|
|
20
|
+
moreHref?: string;
|
|
21
|
+
/** Words for actions of the app's own, by action: { "project.deleted": "Deleted the project {target}" }. */
|
|
22
|
+
labels?: Record<string, string>;
|
|
23
|
+
class?: string;
|
|
24
|
+
};
|
|
25
|
+
declare const ActivityLog: import("svelte").Component<Props, {}, "">;
|
|
26
|
+
type ActivityLog = ReturnType<typeof ActivityLog>;
|
|
27
|
+
export default ActivityLog;
|
package/dist/ui/AuthCard.svelte
CHANGED
|
@@ -43,11 +43,12 @@
|
|
|
43
43
|
color: var(--tern-muted);
|
|
44
44
|
font-size: 0.875rem;
|
|
45
45
|
}
|
|
46
|
-
.
|
|
46
|
+
/* Links in the text, not link buttons, which keep their own colors. */
|
|
47
|
+
.tern-auth :global(a:not(.tern-btn)) {
|
|
47
48
|
color: var(--tern-accent);
|
|
48
49
|
font-weight: 550;
|
|
49
50
|
}
|
|
50
|
-
.tern-auth :global(a:focus-visible) {
|
|
51
|
+
.tern-auth :global(a:not(.tern-btn):focus-visible) {
|
|
51
52
|
outline: 2px solid var(--tern-ring);
|
|
52
53
|
outline-offset: 2px;
|
|
53
54
|
border-radius: 2px;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { tb } from '../runtime/i18n.svelte.js';
|
|
3
|
+
import AccountSection from './AccountSection.svelte';
|
|
4
|
+
import Alert from './Alert.svelte';
|
|
5
|
+
import Badge from './Badge.svelte';
|
|
6
|
+
import Button from './Button.svelte';
|
|
7
|
+
import Form from './Form.svelte';
|
|
8
|
+
import Progress from './Progress.svelte';
|
|
9
|
+
import { day, money, per, type BillingPlan, type BillingSubscription } from './billing.js';
|
|
10
|
+
|
|
11
|
+
type Usage = { limit: string; label: string; used: number };
|
|
12
|
+
type Props = {
|
|
13
|
+
title?: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
level?: 2 | 3 | 4;
|
|
16
|
+
/** The plan that applies, from b.Current. */
|
|
17
|
+
plan: BillingPlan;
|
|
18
|
+
/** The subscription, from b.Current; null for owners who never subscribed. */
|
|
19
|
+
subscription: BillingSubscription | null;
|
|
20
|
+
/** What's used of the plan's limits: [{ limit: "projects", label: "Projects", used: 3 }]. */
|
|
21
|
+
usage?: Usage[];
|
|
22
|
+
/** The action that calls b.Portal and redirects to it. */
|
|
23
|
+
portalAction?: string;
|
|
24
|
+
/** The pricing page, to change plans. */
|
|
25
|
+
pricingHref?: string;
|
|
26
|
+
/** The page's form prop, for the portal action's errors. */
|
|
27
|
+
form?: unknown;
|
|
28
|
+
class?: string;
|
|
29
|
+
};
|
|
30
|
+
let { title, description, level = 2, plan, subscription: s, usage = [], portalAction, pricingHref, form, class: klass = '' }: Props = $props();
|
|
31
|
+
|
|
32
|
+
const active = $derived(!!s && ['active', 'trialing', 'past_due'].includes(s.status));
|
|
33
|
+
const amount = $derived(active && s ? plan.amounts?.[s.interval] : undefined);
|
|
34
|
+
const message = $derived((form as { message?: string } | null)?.message);
|
|
35
|
+
// What happens next, in a sentence.
|
|
36
|
+
const next = $derived.by(() => {
|
|
37
|
+
if (!s || !active) return '';
|
|
38
|
+
if (s.status === 'trialing' && s.trialEnd) return tb('tern.ui.billing.trialEnds', { date: day(s.trialEnd) });
|
|
39
|
+
if (s.status === 'past_due') return tb('tern.ui.billing.pastDue');
|
|
40
|
+
if (s.cancelAtPeriodEnd) return tb('tern.ui.billing.cancels', { date: day(s.currentPeriodEnd) });
|
|
41
|
+
return tb('tern.ui.billing.renews', { date: day(s.currentPeriodEnd) });
|
|
42
|
+
});
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<AccountSection id="tern-billing-title" title={title ?? tb('tern.ui.billing.title')} {description} {level} class={klass}>
|
|
46
|
+
<div class="tern-billing-plan">
|
|
47
|
+
<div class="tern-account-text">
|
|
48
|
+
<span class="tern-account-name">
|
|
49
|
+
{plan.name}
|
|
50
|
+
{#if s?.status === 'trialing'}<Badge tone="accent">{tb('tern.ui.billing.status.trialing')}</Badge>
|
|
51
|
+
{:else if s?.status === 'past_due'}<Badge tone="danger">{tb('tern.ui.billing.status.pastDue')}</Badge>
|
|
52
|
+
{:else if active && s?.cancelAtPeriodEnd}<Badge tone="warning">{tb('tern.ui.billing.status.canceling')}</Badge>{/if}
|
|
53
|
+
</span>
|
|
54
|
+
<span class="tern-account-meta">
|
|
55
|
+
{#if amount !== undefined && s}{money(amount * (plan.perSeat ? s.seats : 1), plan.currency)} {per(s.interval)}{plan.perSeat ? ` · ${tb('tern.ui.billing.seats', { n: s.seats })}` : ''}{:else}{tb('tern.ui.billing.noCard')}{/if}
|
|
56
|
+
</span>
|
|
57
|
+
</div>
|
|
58
|
+
<div class="tern-billing-actions">
|
|
59
|
+
{#if pricingHref}<Button href={pricingHref} variant={active ? 'ghost' : 'primary'} size="sm">{active ? tb('tern.ui.billing.changePlan') : tb('tern.ui.billing.upgrade')}</Button>{/if}
|
|
60
|
+
{#if portalAction && s}
|
|
61
|
+
<Form action={portalAction} reset={false}>
|
|
62
|
+
<Button type="submit" variant="secondary" size="sm">{tb('tern.ui.billing.manage')}</Button>
|
|
63
|
+
</Form>
|
|
64
|
+
{/if}
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
{#if next}<Alert tone={s?.status === 'past_due' ? 'danger' : 'info'}>{next}</Alert>{/if}
|
|
68
|
+
{#if message}<Alert tone="danger" live>{message}</Alert>{/if}
|
|
69
|
+
{#if usage.length}
|
|
70
|
+
<div class="tern-billing-usage">
|
|
71
|
+
{#each usage as u (u.limit)}
|
|
72
|
+
{@const max = plan.limits?.[u.limit]}
|
|
73
|
+
<div class="tern-billing-meter">
|
|
74
|
+
<div class="tern-billing-meter-head">
|
|
75
|
+
<span>{u.label}</span>
|
|
76
|
+
<span class="tern-account-meta">{max === undefined ? tb('tern.ui.billing.unlimitedUse', { used: u.used }) : tb('tern.ui.billing.used', { used: u.used, max })}</span>
|
|
77
|
+
</div>
|
|
78
|
+
{#if max !== undefined}<Progress value={Math.min(u.used, max)} max={Math.max(max, 1)} label={u.label} />{/if}
|
|
79
|
+
</div>
|
|
80
|
+
{/each}
|
|
81
|
+
</div>
|
|
82
|
+
{/if}
|
|
83
|
+
</AccountSection>
|
|
84
|
+
|
|
85
|
+
<style>
|
|
86
|
+
.tern-billing-plan {
|
|
87
|
+
display: flex;
|
|
88
|
+
align-items: center;
|
|
89
|
+
justify-content: space-between;
|
|
90
|
+
gap: 1rem;
|
|
91
|
+
flex-wrap: wrap;
|
|
92
|
+
padding: 1rem;
|
|
93
|
+
border: 1px solid var(--tern-border);
|
|
94
|
+
border-radius: var(--tern-radius);
|
|
95
|
+
background: var(--tern-surface);
|
|
96
|
+
}
|
|
97
|
+
.tern-billing-plan .tern-account-name {
|
|
98
|
+
font-size: 1.0625rem;
|
|
99
|
+
}
|
|
100
|
+
.tern-billing-actions {
|
|
101
|
+
display: flex;
|
|
102
|
+
align-items: center;
|
|
103
|
+
gap: 0.5rem;
|
|
104
|
+
}
|
|
105
|
+
.tern-billing-usage {
|
|
106
|
+
display: grid;
|
|
107
|
+
gap: 1rem;
|
|
108
|
+
}
|
|
109
|
+
.tern-billing-meter {
|
|
110
|
+
display: grid;
|
|
111
|
+
gap: 0.4rem;
|
|
112
|
+
}
|
|
113
|
+
.tern-billing-meter-head {
|
|
114
|
+
display: flex;
|
|
115
|
+
justify-content: space-between;
|
|
116
|
+
gap: 1rem;
|
|
117
|
+
font-size: 0.875rem;
|
|
118
|
+
font-weight: 550;
|
|
119
|
+
}
|
|
120
|
+
</style>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type BillingPlan, type BillingSubscription } from './billing.js';
|
|
2
|
+
type Usage = {
|
|
3
|
+
limit: string;
|
|
4
|
+
label: string;
|
|
5
|
+
used: number;
|
|
6
|
+
};
|
|
7
|
+
type Props = {
|
|
8
|
+
title?: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
level?: 2 | 3 | 4;
|
|
11
|
+
/** The plan that applies, from b.Current. */
|
|
12
|
+
plan: BillingPlan;
|
|
13
|
+
/** The subscription, from b.Current; null for owners who never subscribed. */
|
|
14
|
+
subscription: BillingSubscription | null;
|
|
15
|
+
/** What's used of the plan's limits: [{ limit: "projects", label: "Projects", used: 3 }]. */
|
|
16
|
+
usage?: Usage[];
|
|
17
|
+
/** The action that calls b.Portal and redirects to it. */
|
|
18
|
+
portalAction?: string;
|
|
19
|
+
/** The pricing page, to change plans. */
|
|
20
|
+
pricingHref?: string;
|
|
21
|
+
/** The page's form prop, for the portal action's errors. */
|
|
22
|
+
form?: unknown;
|
|
23
|
+
class?: string;
|
|
24
|
+
};
|
|
25
|
+
declare const BillingSettings: import("svelte").Component<Props, {}, "">;
|
|
26
|
+
type BillingSettings = ReturnType<typeof BillingSettings>;
|
|
27
|
+
export default BillingSettings;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { tb } from '../runtime/i18n.svelte.js';
|
|
3
|
+
import AuthCard from './AuthCard.svelte';
|
|
4
|
+
import Button from './Button.svelte';
|
|
5
|
+
import Field from './Field.svelte';
|
|
6
|
+
import Form from './Form.svelte';
|
|
7
|
+
import Input from './Input.svelte';
|
|
8
|
+
|
|
9
|
+
type Props = {
|
|
10
|
+
title?: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
level?: 1 | 2 | 3;
|
|
13
|
+
/** The action that calls acc.CreateTeam with the posted name. */
|
|
14
|
+
action?: string;
|
|
15
|
+
/** The page's form prop, for the action's errors. */
|
|
16
|
+
form?: unknown;
|
|
17
|
+
/** A link back, such as to the dashboard, for users who already have a team. */
|
|
18
|
+
cancelHref?: string;
|
|
19
|
+
class?: string;
|
|
20
|
+
};
|
|
21
|
+
let { title, description, level = 1, action = '?/create', form, cancelHref, class: klass = '' }: Props = $props();
|
|
22
|
+
let submitting = $state(false);
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<AuthCard id="tern-new-team-title" title={title ?? tb('tern.ui.team.new.title')} description={description ?? tb('tern.ui.team.new.description')} {level} class={klass}>
|
|
26
|
+
<Form {action} {form} bind:submitting reset={false}>
|
|
27
|
+
<Field label={tb('tern.ui.team.new.name')} name="name"><Input required maxlength={100} autocomplete="organization" /></Field>
|
|
28
|
+
<Button type="submit" loading={submitting}>{tb('tern.ui.team.new.submit')}</Button>
|
|
29
|
+
</Form>
|
|
30
|
+
{#if cancelHref}<p class="tern-auth-note"><a href={cancelHref}>{tb('tern.ui.team.new.cancel')}</a></p>{/if}
|
|
31
|
+
</AuthCard>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
type Props = {
|
|
2
|
+
title?: string;
|
|
3
|
+
description?: string;
|
|
4
|
+
level?: 1 | 2 | 3;
|
|
5
|
+
/** The action that calls acc.CreateTeam with the posted name. */
|
|
6
|
+
action?: string;
|
|
7
|
+
/** The page's form prop, for the action's errors. */
|
|
8
|
+
form?: unknown;
|
|
9
|
+
/** A link back, such as to the dashboard, for users who already have a team. */
|
|
10
|
+
cancelHref?: string;
|
|
11
|
+
class?: string;
|
|
12
|
+
};
|
|
13
|
+
declare const NewTeam: import("svelte").Component<Props, {}, "">;
|
|
14
|
+
type NewTeam = ReturnType<typeof NewTeam>;
|
|
15
|
+
export default NewTeam;
|