@svadmin/ui 0.44.0 → 0.46.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/components/AuthPageShell.svelte +32 -0
- package/dist/components/AuthPageShell.svelte.d.ts +11 -0
- package/dist/components/ForgotPasswordPage.svelte +6 -74
- package/dist/components/LoginPage.svelte +34 -125
- package/dist/components/PermissionMatrix.svelte +5 -5
- package/dist/components/ProfilePage.svelte +4 -23
- package/dist/components/RegisterPage.svelte +7 -62
- package/dist/components/SecuritySettings.svelte +4 -10
- package/dist/components/SvadminLogo.svelte +1 -1
- package/dist/components/Toast.svelte +7 -5
- package/dist/components/UpdatePasswordPage.svelte +3 -55
- package/dist/components/account/CompanyProfilePage.svelte +11 -5
- package/dist/components/account/SettingsEnterprisePage.svelte +25 -4
- package/dist/components/account/SettingsPlainPage.svelte +25 -3
- package/dist/components/account/SettingsSidebarPage.svelte +31 -2
- package/dist/components/content/ApiKeyList.svelte +13 -4
- package/dist/components/content/ApiKeyList.svelte.d.ts +7 -0
- package/dist/components/content/DataState.svelte +25 -9
- package/dist/components/content/DataState.svelte.d.ts +2 -0
- package/dist/components/content/FeedbackNotice.svelte +61 -0
- package/dist/components/content/FeedbackNotice.svelte.d.ts +13 -0
- package/dist/components/content/FileList.svelte +12 -4
- package/dist/components/content/FileList.svelte.d.ts +9 -0
- package/dist/components/content/FilterToolbar.svelte +10 -2
- package/dist/components/content/FilterToolbar.svelte.d.ts +1 -0
- package/dist/components/content/MemberList.svelte +13 -5
- package/dist/components/content/MemberList.svelte.d.ts +9 -0
- package/dist/components/content/MetricBlock.svelte +19 -2
- package/dist/components/content/MetricBlock.svelte.d.ts +2 -0
- package/dist/components/content/NetworkTable.svelte +9 -3
- package/dist/components/content/NetworkTable.svelte.d.ts +9 -0
- package/dist/components/content/NetworkUserCard.svelte +47 -7
- package/dist/components/content/NetworkUserCard.svelte.d.ts +13 -0
- package/dist/components/content/SectionHeader.svelte +3 -2
- package/dist/components/content/SectionHeader.svelte.d.ts +1 -0
- package/dist/components/content/SecurityEventTable.svelte +14 -5
- package/dist/components/content/SecurityEventTable.svelte.d.ts +9 -0
- package/dist/components/content/SettingsFieldRow.svelte +28 -0
- package/dist/components/content/SettingsFieldRow.svelte.d.ts +11 -0
- package/dist/components/content/SettingsGroup.svelte +33 -0
- package/dist/components/content/SettingsGroup.svelte.d.ts +12 -0
- package/dist/components/content/WorkspaceLayout.svelte +28 -0
- package/dist/components/content/WorkspaceLayout.svelte.d.ts +11 -0
- package/dist/components/content/index.d.ts +6 -1
- package/dist/components/content/index.js +4 -0
- package/dist/components/network/UserCardsNFTPage.svelte +25 -6
- package/dist/index.d.ts +8 -1
- package/dist/index.js +5 -0
- package/package.json +2 -2
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { useNotification } from '@svadmin/core';
|
|
2
3
|
import { useTranslation } from '@svadmin/core/i18n';
|
|
3
4
|
|
|
4
|
-
import { AlertTriangle,
|
|
5
|
+
import { AlertTriangle, KeyRound, Monitor, Shield, ShieldAlert, ShieldCheck, Smartphone, Trash2 } from '@lucide/svelte';
|
|
5
6
|
import type { Component } from 'svelte';
|
|
6
7
|
import * as Alert from './ui/alert/index.js';
|
|
7
8
|
import * as Card from './ui/card/index.js';
|
|
@@ -11,6 +12,7 @@
|
|
|
11
12
|
import PasswordInput from './PasswordInput.svelte';
|
|
12
13
|
|
|
13
14
|
const i18n = useTranslation();
|
|
15
|
+
const notification = useNotification();
|
|
14
16
|
|
|
15
17
|
interface SessionInfo {
|
|
16
18
|
id: string;
|
|
@@ -32,13 +34,11 @@
|
|
|
32
34
|
let currentPassword = $state('');
|
|
33
35
|
let newPassword = $state('');
|
|
34
36
|
let confirmPassword = $state('');
|
|
35
|
-
let passwordSuccessMessage = $state('');
|
|
36
37
|
let passwordErrorMessage = $state('');
|
|
37
38
|
let lastChanged = $state('3 months ago');
|
|
38
39
|
|
|
39
40
|
function handlePasswordChange(event: SubmitEvent) {
|
|
40
41
|
event.preventDefault();
|
|
41
|
-
passwordSuccessMessage = '';
|
|
42
42
|
passwordErrorMessage = '';
|
|
43
43
|
|
|
44
44
|
if (!currentPassword) {
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
return;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
notification.success(i18n.t('profile.passwordChanged'), 3000);
|
|
58
58
|
lastChanged = 'Just now';
|
|
59
59
|
currentPassword = '';
|
|
60
60
|
newPassword = '';
|
|
@@ -111,12 +111,6 @@
|
|
|
111
111
|
</Card.CardHeader>
|
|
112
112
|
<Card.CardContent>
|
|
113
113
|
<form onsubmit={handlePasswordChange} class="max-w-sm space-y-4">
|
|
114
|
-
{#if passwordSuccessMessage}
|
|
115
|
-
<Alert.Root class="border-success/30 bg-success/5 text-success">
|
|
116
|
-
<CheckCircle2 class="h-4 w-4" />
|
|
117
|
-
<Alert.Description>{passwordSuccessMessage}</Alert.Description>
|
|
118
|
-
</Alert.Root>
|
|
119
|
-
{/if}
|
|
120
114
|
{#if passwordErrorMessage}
|
|
121
115
|
<Alert.Root variant="destructive">
|
|
122
116
|
<AlertTriangle class="h-4 w-4" />
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
<span
|
|
12
12
|
aria-hidden="true"
|
|
13
|
-
class="inline-block shrink-0 rounded-lg bg-
|
|
13
|
+
class="inline-block shrink-0 rounded-lg bg-primary shadow-sm {onDark ? 'ring-1 ring-white/35' : ''}"
|
|
14
14
|
style:width={`${size}px`}
|
|
15
15
|
style:height={`${size}px`}
|
|
16
16
|
></span>
|
|
@@ -20,11 +20,12 @@
|
|
|
20
20
|
const queue = getToastQueue();
|
|
21
21
|
if (queue.length > 0) {
|
|
22
22
|
for (const t of queue) {
|
|
23
|
+
const options = { duration: t.duration, id: t.key ?? t.id };
|
|
23
24
|
switch (t.type) {
|
|
24
|
-
case 'success': sonner.success(t.message,
|
|
25
|
-
case 'error': sonner.error(t.message,
|
|
26
|
-
case 'warning': sonner.warning(t.message,
|
|
27
|
-
case 'info': sonner.info(t.message,
|
|
25
|
+
case 'success': sonner.success(t.message, options); break;
|
|
26
|
+
case 'error': sonner.error(t.message, options); break;
|
|
27
|
+
case 'warning': sonner.warning(t.message, options); break;
|
|
28
|
+
case 'info': sonner.info(t.message, options); break;
|
|
28
29
|
}
|
|
29
30
|
}
|
|
30
31
|
consumeToastQueue();
|
|
@@ -46,7 +47,8 @@
|
|
|
46
47
|
position: "top-right",
|
|
47
48
|
richColors: true,
|
|
48
49
|
closeButton: true,
|
|
49
|
-
expand:
|
|
50
|
+
expand: false,
|
|
51
|
+
visibleToasts: 3,
|
|
50
52
|
theme,
|
|
51
53
|
toastOptions: {
|
|
52
54
|
classes: {
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
import { Button } from './ui/button/index.js';
|
|
6
6
|
import * as Alert from './ui/alert/index.js';
|
|
7
|
+
import AuthPageShell from './AuthPageShell.svelte';
|
|
7
8
|
import PasswordInput from './PasswordInput.svelte';
|
|
8
|
-
import SvadminLogo from './SvadminLogo.svelte';
|
|
9
9
|
import { Loader2, AlertCircle } from '@lucide/svelte';
|
|
10
10
|
|
|
11
11
|
const i18n = useTranslation();
|
|
@@ -35,53 +35,7 @@
|
|
|
35
35
|
}
|
|
36
36
|
</script>
|
|
37
37
|
|
|
38
|
-
<
|
|
39
|
-
<!-- Left brand panel -->
|
|
40
|
-
<div class="hidden lg:flex lg:w-[480px] xl:w-[520px] relative overflow-hidden bg-gradient-to-br from-primary/90 via-primary to-primary/80 shrink-0">
|
|
41
|
-
<div class="absolute inset-0">
|
|
42
|
-
<div class="absolute top-[12%] left-[8%] w-28 h-28 rounded-3xl border border-white/10 bg-white/5 rotate-12 backdrop-blur-sm"></div>
|
|
43
|
-
<div class="absolute top-[55%] left-[55%] w-40 h-40 rounded-full border border-white/10 bg-white/5 -rotate-6 backdrop-blur-sm"></div>
|
|
44
|
-
<div class="absolute bottom-[15%] left-[15%] w-24 h-24 rounded-2xl border border-white/10 bg-white/5 -rotate-12 backdrop-blur-sm"></div>
|
|
45
|
-
<div class="absolute top-[30%] right-[10%] w-16 h-16 rounded-xl border border-white/10 bg-white/5 rotate-45 backdrop-blur-sm"></div>
|
|
46
|
-
<div class="absolute inset-0 opacity-[0.03]" style="background-image: radial-gradient(circle, white 1px, transparent 1px); background-size: 24px 24px;"></div>
|
|
47
|
-
</div>
|
|
48
|
-
|
|
49
|
-
<div class="relative z-10 flex flex-col justify-between p-10 xl:p-12 w-full">
|
|
50
|
-
<div class="flex items-center gap-3">
|
|
51
|
-
<SvadminLogo size={36} onDark />
|
|
52
|
-
<span class="text-lg font-semibold text-white tracking-tight">{title}</span>
|
|
53
|
-
</div>
|
|
54
|
-
|
|
55
|
-
<div class="space-y-4">
|
|
56
|
-
<h2 class="text-3xl xl:text-4xl font-bold text-white leading-tight">
|
|
57
|
-
{i18n.t('auth.resetPassword')}
|
|
58
|
-
</h2>
|
|
59
|
-
<p class="text-base text-white/70 leading-relaxed max-w-[320px]">
|
|
60
|
-
{i18n.t('auth.resetPasswordDescription')}
|
|
61
|
-
</p>
|
|
62
|
-
</div>
|
|
63
|
-
|
|
64
|
-
<div class="flex items-center gap-3">
|
|
65
|
-
<div class="h-1 w-8 rounded-full bg-white/30"></div>
|
|
66
|
-
<div class="h-1 w-4 rounded-full bg-white/15"></div>
|
|
67
|
-
<div class="h-1 w-2 rounded-full bg-white/10"></div>
|
|
68
|
-
</div>
|
|
69
|
-
</div>
|
|
70
|
-
</div>
|
|
71
|
-
|
|
72
|
-
<!-- Right form panel -->
|
|
73
|
-
<div class="flex-1 flex items-center justify-center bg-background p-6 sm:p-8 lg:p-12">
|
|
74
|
-
<div class="w-full max-w-[400px]">
|
|
75
|
-
<div class="mb-8 flex items-center gap-3 lg:hidden">
|
|
76
|
-
<SvadminLogo size={36} />
|
|
77
|
-
<span class="text-lg font-semibold tracking-tight">{title}</span>
|
|
78
|
-
</div>
|
|
79
|
-
|
|
80
|
-
<div class="space-y-1 mb-8">
|
|
81
|
-
<h1 class="text-2xl font-semibold tracking-tight">{i18n.t('auth.resetPassword')}</h1>
|
|
82
|
-
<p class="text-sm text-muted-foreground">{i18n.t('auth.resetPasswordDescription')}</p>
|
|
83
|
-
</div>
|
|
84
|
-
|
|
38
|
+
<AuthPageShell brand={title} title={i18n.t('auth.resetPassword')} description={i18n.t('auth.resetPasswordDescription')}>
|
|
85
39
|
<form onsubmit={handleSubmit} class="space-y-5">
|
|
86
40
|
{#if error}
|
|
87
41
|
<Alert.Root variant="destructive">
|
|
@@ -118,10 +72,4 @@
|
|
|
118
72
|
</Button>
|
|
119
73
|
</div>
|
|
120
74
|
</form>
|
|
121
|
-
|
|
122
|
-
<p class="text-xs text-muted-foreground/60 mt-8 text-center">
|
|
123
|
-
Powered by {title}
|
|
124
|
-
</p>
|
|
125
|
-
</div>
|
|
126
|
-
</div>
|
|
127
|
-
</div>
|
|
75
|
+
</AuthPageShell>
|
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
import { Button } from '../ui/button/index.js';
|
|
5
5
|
import { Input } from '../ui/input/index.js';
|
|
6
6
|
import { Label } from '../ui/label/index.js';
|
|
7
|
-
import
|
|
7
|
+
import { Textarea } from '../ui/textarea/index.js';
|
|
8
8
|
import ContentPageShell from '../content/ContentPageShell.svelte';
|
|
9
9
|
import ContentPageHeader from '../content/ContentPageHeader.svelte';
|
|
10
10
|
import DescriptionList from '../content/DescriptionList.svelte';
|
|
11
11
|
import MemberList from '../content/MemberList.svelte';
|
|
12
|
+
import SectionHeader from '../content/SectionHeader.svelte';
|
|
13
|
+
import SettingsGroup from '../content/SettingsGroup.svelte';
|
|
12
14
|
import type { MemberSummary } from '../content/MemberList.svelte';
|
|
13
15
|
const i18n = useTranslation();
|
|
14
16
|
let companyName = $state('Acme Corporation');
|
|
@@ -25,11 +27,15 @@
|
|
|
25
27
|
async function save() { saving = true; await new Promise((resolve) => setTimeout(resolve, 400)); saving = false; }
|
|
26
28
|
</script>
|
|
27
29
|
|
|
30
|
+
{#snippet headerActions()}
|
|
31
|
+
<Button onclick={save} disabled={saving} size="sm">{#if saving}<Loader2 class="size-3.5 animate-spin" />{/if}{i18n.t('common.save')}</Button>
|
|
32
|
+
{/snippet}
|
|
33
|
+
|
|
28
34
|
<ContentPageShell pageId="account-company-profile" width="wide">
|
|
29
|
-
<
|
|
35
|
+
<ContentPageHeader title={i18n.t('account.companyProfile')} description={i18n.t('account.companyProfileDescription')} actions={headerActions} />
|
|
30
36
|
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_20rem]">
|
|
31
|
-
<
|
|
32
|
-
<
|
|
37
|
+
<SettingsGroup title={i18n.t('account.basicSettings')} description={i18n.t('account.companyProfileDescription')} bodyClass="space-y-4"><div class="grid gap-4 sm:grid-cols-2"><div class="space-y-2"><Label for="company-name">{i18n.t('profile.companyName')}</Label><Input id="company-name" bind:value={companyName} /></div><div class="space-y-2"><Label for="company-industry">{i18n.t('profile.industry')}</Label><Input id="company-industry" bind:value={industry} /></div><div class="space-y-2 sm:col-span-2"><Label for="company-website">{i18n.t('profile.website')}</Label><Input id="company-website" bind:value={website} type="url" /></div></div><div class="space-y-2"><Label for="company-description">{i18n.t('profile.companyDescription')}</Label><Textarea id="company-description" bind:value={description} rows={4} /></div></SettingsGroup>
|
|
38
|
+
<SettingsGroup title={i18n.t('profile.companyName')} description={industry} bodyClass="space-y-4"><p class="text-lg font-semibold text-foreground">{companyName}</p><DescriptionList columns={1} items={[{ label: i18n.t('profile.website'), value: website, href: website }, { label: i18n.t('profile.employees'), value: '1,250' }, { label: i18n.t('profile.founded'), value: '2015' }]} /></SettingsGroup>
|
|
33
39
|
</div>
|
|
34
|
-
<section class="space-y-3"><
|
|
40
|
+
<section class="space-y-3"><SectionHeader title={i18n.t('profileSections.members')} /><MemberList {members} emptyTitle={i18n.t('common.noData')} emptyDescription={i18n.t('empty.description')} /></section>
|
|
35
41
|
</ContentPageShell>
|
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
import { Input } from '../ui/input/index.js';
|
|
5
5
|
import { Label } from '../ui/label/index.js';
|
|
6
6
|
import { Switch } from '../ui/switch/index.js';
|
|
7
|
-
import * as Card from '../ui/card/index.js';
|
|
8
7
|
import ContentPageShell from '../content/ContentPageShell.svelte';
|
|
9
8
|
import ContentPageHeader from '../content/ContentPageHeader.svelte';
|
|
9
|
+
import SectionHeader from '../content/SectionHeader.svelte';
|
|
10
|
+
import SettingsGroup from '../content/SettingsGroup.svelte';
|
|
11
|
+
import SettingsFieldRow from '../content/SettingsFieldRow.svelte';
|
|
10
12
|
import IntegrationCard from '../content/IntegrationCard.svelte';
|
|
11
13
|
import type { IntegrationSummary } from '../content/IntegrationCard.svelte';
|
|
12
14
|
import SecurityEventTable from '../content/SecurityEventTable.svelte';
|
|
@@ -18,9 +20,28 @@
|
|
|
18
20
|
const events: SecurityEvent[] = [{ id: '1', event: 'SSO policy updated', actor: 'Alex Chen', location: 'Shanghai, CN', createdAt: 'Today, 09:12', severity: 'info' }, { id: '2', event: 'Audit log retention changed', actor: 'Alex Chen', location: 'Shanghai, CN', createdAt: 'Yesterday, 16:40', severity: 'warning' }];
|
|
19
21
|
</script>
|
|
20
22
|
|
|
23
|
+
{#snippet auditControl()}
|
|
24
|
+
<Switch bind:checked={auditLogging} aria-label={i18n.t('account.auditLogging')} />
|
|
25
|
+
{/snippet}
|
|
26
|
+
|
|
27
|
+
{#snippet ssoControl()}
|
|
28
|
+
<Switch bind:checked={ssoEnabled} aria-label={i18n.t('account.enableSso')} />
|
|
29
|
+
{/snippet}
|
|
30
|
+
|
|
21
31
|
<ContentPageShell pageId="account-settings-enterprise" width="wide">
|
|
22
32
|
<ContentPageHeader title={i18n.t('account.settingsEnterprise')} description={i18n.t('account.settingsEnterpriseDescription')} />
|
|
23
|
-
<div class="grid gap-4 lg:grid-cols-2"
|
|
24
|
-
|
|
25
|
-
|
|
33
|
+
<div class="grid gap-4 lg:grid-cols-2">
|
|
34
|
+
<SettingsGroup title={i18n.t('account.securityPolicy')} description="Set organization-wide controls for authentication and sessions." bodyClass="space-y-4">
|
|
35
|
+
<div class="grid gap-4 sm:grid-cols-2"><div class="space-y-2"><Label for="session-timeout">{i18n.t('account.sessionTimeout')}</Label><Input id="session-timeout" value="30" /></div><div class="space-y-2"><Label for="retention">{i18n.t('account.dataRetention')}</Label><Input id="retention" value="365" /></div></div>
|
|
36
|
+
<SettingsFieldRow label={i18n.t('account.auditLogging')} description="Preserve an auditable record of organization-level changes." control={auditControl} separated />
|
|
37
|
+
<Button size="sm">{i18n.t('common.save')}</Button>
|
|
38
|
+
</SettingsGroup>
|
|
39
|
+
<SettingsGroup title={i18n.t('account.singleSignOn')} description={i18n.t('account.ssoDescription')} bodyClass="space-y-4">
|
|
40
|
+
<SettingsFieldRow label={i18n.t('account.enableSso')} description="Require the configured identity provider for organization members." control={ssoControl} />
|
|
41
|
+
<Input value="https://idp.example.com/metadata" aria-label={i18n.t('account.ssoProvider')} />
|
|
42
|
+
<Button variant="outline" size="sm">{i18n.t('common.test')}</Button>
|
|
43
|
+
</SettingsGroup>
|
|
44
|
+
</div>
|
|
45
|
+
<section class="space-y-3"><SectionHeader title={i18n.t('account.connections')} />{#each identity as integration (integration.id)}<IntegrationCard {integration} />{/each}</section>
|
|
46
|
+
<section class="space-y-3"><SectionHeader title={i18n.t('account.securityLog')} /><SecurityEventTable events={events} emptyTitle={i18n.t('common.noData')} emptyDescription={i18n.t('empty.description')} /></section>
|
|
26
47
|
</ContentPageShell>
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
import { Input } from '../ui/input/index.js';
|
|
5
5
|
import { Label } from '../ui/label/index.js';
|
|
6
6
|
import { Switch } from '../ui/switch/index.js';
|
|
7
|
-
import * as Card from '../ui/card/index.js';
|
|
8
7
|
import ContentPageShell from '../content/ContentPageShell.svelte';
|
|
9
8
|
import ContentPageHeader from '../content/ContentPageHeader.svelte';
|
|
9
|
+
import SettingsGroup from '../content/SettingsGroup.svelte';
|
|
10
|
+
import SettingsFieldRow from '../content/SettingsFieldRow.svelte';
|
|
10
11
|
const i18n = useTranslation();
|
|
11
12
|
let displayName = $state('Alex Chen');
|
|
12
13
|
let email = $state('alex@example.com');
|
|
@@ -16,7 +17,28 @@
|
|
|
16
17
|
async function save() { saving = true; await new Promise((resolve) => setTimeout(resolve, 400)); saving = false; }
|
|
17
18
|
</script>
|
|
18
19
|
|
|
20
|
+
{#snippet headerActions()}
|
|
21
|
+
<Button size="sm" onclick={save} disabled={saving}>{i18n.t('common.save')}</Button>
|
|
22
|
+
{/snippet}
|
|
23
|
+
|
|
24
|
+
{#snippet notificationControl()}
|
|
25
|
+
<Switch bind:checked={notifications} aria-label={i18n.t('settings.notifications')} />
|
|
26
|
+
{/snippet}
|
|
27
|
+
|
|
28
|
+
{#snippet twoFactorControl()}
|
|
29
|
+
<Switch bind:checked={twoFactor} aria-label={i18n.t('account.twoFactorAuth')} />
|
|
30
|
+
{/snippet}
|
|
31
|
+
|
|
19
32
|
<ContentPageShell pageId="account-settings-plain" width="narrow">
|
|
20
|
-
<
|
|
21
|
-
<
|
|
33
|
+
<ContentPageHeader title={i18n.t('account.settingsPlain')} description={i18n.t('account.settingsPlainDescription')} actions={headerActions} />
|
|
34
|
+
<SettingsGroup title={i18n.t('account.basicSettings')} description={i18n.t('account.settingsPlainDescription')} bodyClass="space-y-5">
|
|
35
|
+
<div class="grid gap-4 sm:grid-cols-2">
|
|
36
|
+
<div class="space-y-2"><Label for="settings-name">{i18n.t('profile.name')}</Label><Input id="settings-name" bind:value={displayName} /></div>
|
|
37
|
+
<div class="space-y-2"><Label for="settings-email">Email</Label><Input id="settings-email" bind:value={email} type="email" /></div>
|
|
38
|
+
</div>
|
|
39
|
+
<div>
|
|
40
|
+
<SettingsFieldRow label={i18n.t('settings.notifications')} description="Receive account and workflow updates." control={notificationControl} />
|
|
41
|
+
<SettingsFieldRow label={i18n.t('account.twoFactorAuth')} description="Require an additional verification code when signing in." control={twoFactorControl} separated />
|
|
42
|
+
</div>
|
|
43
|
+
</SettingsGroup>
|
|
22
44
|
</ContentPageShell>
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
import { Button } from '../ui/button/index.js';
|
|
4
4
|
import { Input } from '../ui/input/index.js';
|
|
5
5
|
import { Switch } from '../ui/switch/index.js';
|
|
6
|
-
import * as Card from '../ui/card/index.js';
|
|
7
6
|
import ContentPageShell from '../content/ContentPageShell.svelte';
|
|
8
7
|
import ContentPageHeader from '../content/ContentPageHeader.svelte';
|
|
8
|
+
import SettingsGroup from '../content/SettingsGroup.svelte';
|
|
9
|
+
import SettingsFieldRow from '../content/SettingsFieldRow.svelte';
|
|
9
10
|
const i18n = useTranslation();
|
|
10
11
|
const sections = [{ id: 'profile', title: i18n.t('account.basicSettings') }, { id: 'signin', title: i18n.t('account.socialSignIn') }, { id: 'preferences', title: i18n.t('account.preferences') }, { id: 'api', title: i18n.t('account.manageApi') }];
|
|
11
12
|
let active = $state('profile');
|
|
@@ -13,7 +14,35 @@
|
|
|
13
14
|
let available = $state(true);
|
|
14
15
|
</script>
|
|
15
16
|
|
|
17
|
+
{#snippet visibilityControl()}
|
|
18
|
+
<Switch bind:checked={visible} aria-label={i18n.t('account.visibility')} />
|
|
19
|
+
{/snippet}
|
|
20
|
+
|
|
21
|
+
{#snippet availabilityControl()}
|
|
22
|
+
<Switch bind:checked={available} aria-label={i18n.t('account.availability')} />
|
|
23
|
+
{/snippet}
|
|
24
|
+
|
|
16
25
|
<ContentPageShell pageId="account-settings-sidebar" width="wide">
|
|
17
26
|
<ContentPageHeader title={i18n.t('account.settingsSidebar')} description={i18n.t('account.settingsSidebarDescription')} />
|
|
18
|
-
<div class="grid gap-6 lg:grid-cols-[12rem_minmax(0,1fr)]"
|
|
27
|
+
<div class="grid gap-6 lg:grid-cols-[12rem_minmax(0,1fr)]">
|
|
28
|
+
<nav class="flex gap-1 overflow-x-auto lg:flex-col" aria-label={i18n.t('account.settingsSidebar')}>
|
|
29
|
+
{#each sections as section (section.id)}
|
|
30
|
+
<button type="button" class={'whitespace-nowrap rounded-md px-3 py-2 text-left text-sm ' + (active === section.id ? 'bg-muted font-medium text-foreground' : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground')} aria-current={active === section.id ? 'page' : undefined} onclick={() => active = section.id}>{section.title}</button>
|
|
31
|
+
{/each}
|
|
32
|
+
</nav>
|
|
33
|
+
<div class="min-w-0">
|
|
34
|
+
{#if active === 'profile'}
|
|
35
|
+
<SettingsGroup title={i18n.t('account.basicSettings')} description={i18n.t('account.settingsPlainDescription')} bodyClass="space-y-4">
|
|
36
|
+
<div class="grid gap-4 sm:grid-cols-2"><Input value="Alex Chen" aria-label={i18n.t('profile.name')} /><Input value="Nebula Labs" aria-label="Company" /></div>
|
|
37
|
+
<div><SettingsFieldRow label={i18n.t('account.visibility')} control={visibilityControl} separated /><SettingsFieldRow label={i18n.t('account.availability')} control={availabilityControl} separated /></div>
|
|
38
|
+
</SettingsGroup>
|
|
39
|
+
{:else if active === 'signin'}
|
|
40
|
+
<SettingsGroup title={i18n.t('account.socialSignIn')} description="Connect the providers your team trusts for sign in." bodyClass="flex flex-wrap gap-2"><Button variant="outline">GitHub</Button><Button variant="outline">Google</Button></SettingsGroup>
|
|
41
|
+
{:else if active === 'preferences'}
|
|
42
|
+
<SettingsGroup title={i18n.t('account.preferences')} bodyClass="grid gap-4 sm:grid-cols-3"><Input value="English" aria-label={i18n.t('account.language')} /><Input value="Asia/Shanghai" aria-label={i18n.t('account.timezone')} /><Input value="CNY" aria-label={i18n.t('account.currency')} /></SettingsGroup>
|
|
43
|
+
{:else}
|
|
44
|
+
<SettingsGroup title={i18n.t('account.manageApi')} description="Use scoped credentials and rotate them when access changes." bodyClass="flex flex-col gap-3 sm:flex-row"><Input readonly value="sv_demo_pub_9f3e********41ab" aria-label="API key" /><Button variant="outline">{i18n.t('common.copy')}</Button></SettingsGroup>
|
|
45
|
+
{/if}
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
19
48
|
</ContentPageShell>
|
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { Copy, KeyRound, Trash2 } from '@lucide/svelte';
|
|
3
|
+
import { useTranslation } from '@svadmin/core/i18n';
|
|
3
4
|
import { Button } from '../ui/button/index.js';
|
|
5
|
+
import DataState from './DataState.svelte';
|
|
6
|
+
import type { DataStateKind } from './DataState.svelte';
|
|
4
7
|
export interface ApiKeySummary { id: string; name: string; prefix: string; createdAt?: string; lastUsedAt?: string; }
|
|
5
|
-
interface Props { keys?: ApiKeySummary[]; oncopy?: (key: ApiKeySummary) => void; onrevoke?: (key: ApiKeySummary) => void; class?: string; }
|
|
6
|
-
|
|
8
|
+
interface Props { keys?: ApiKeySummary[]; state?: DataStateKind; stateTitle?: string; stateDescription?: string; retry?: () => void; retryLabel?: string; loadingLabel?: string; oncopy?: (key: ApiKeySummary) => void; onrevoke?: (key: ApiKeySummary) => void; class?: string; }
|
|
9
|
+
const i18n = useTranslation();
|
|
10
|
+
let { keys = [], state, stateTitle, stateDescription, retry, retryLabel, loadingLabel, oncopy, onrevoke, class: className = '' }: Props = $props();
|
|
11
|
+
const resolvedState = $derived(state ?? (keys.length === 0 ? 'empty' : undefined));
|
|
12
|
+
const isZh = $derived(i18n.locale === 'zh-CN');
|
|
7
13
|
</script>
|
|
14
|
+
{#if resolvedState}
|
|
15
|
+
<DataState state={resolvedState} title={stateTitle} description={stateDescription} {retry} {retryLabel} {loadingLabel} class={className} />
|
|
16
|
+
{:else}
|
|
8
17
|
<div class={'divide-y divide-border overflow-hidden rounded-lg border border-border bg-card ' + className}>
|
|
9
18
|
{#each keys as apiKey (apiKey.id)}
|
|
10
|
-
<div class="flex flex-wrap items-center gap-3 px-4 py-3"><span class="flex size-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary"><KeyRound class="size-4" /></span><div class="min-w-0 flex-1"><p class="truncate text-sm font-medium text-foreground">{apiKey.name}</p><p class="font-mono text-xs text-muted-foreground">{apiKey.prefix}********</p></div><div class="text-right text-xs text-muted-foreground"><p>
|
|
19
|
+
<div class="flex flex-wrap items-center gap-3 px-4 py-3"><span class="flex size-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary"><KeyRound class="size-4" /></span><div class="min-w-0 flex-1"><p class="truncate text-sm font-medium text-foreground">{apiKey.name}</p><p class="font-mono text-xs text-muted-foreground">{apiKey.prefix}********</p></div><div class="text-right text-xs text-muted-foreground"><p>{apiKey.createdAt ?? (isZh ? '最近创建' : 'Created recently')}</p><p>{apiKey.lastUsedAt ?? (isZh ? '从未使用' : 'Never used')}</p></div><div class="flex items-center gap-1">{#if oncopy}<Button variant="ghost" size="icon-sm" aria-label={i18n.t('common.copy') + ' ' + apiKey.name} onclick={() => oncopy?.(apiKey)}><Copy class="size-4" /></Button>{/if}{#if onrevoke}<Button variant="ghost" size="icon-sm" aria-label={i18n.t('common.delete') + ' ' + apiKey.name} onclick={() => onrevoke?.(apiKey)}><Trash2 class="size-4" /></Button>{/if}</div></div>
|
|
11
20
|
{/each}
|
|
12
|
-
{#if keys.length === 0}<div class="p-6 text-center text-sm text-muted-foreground">No API keys</div>{/if}
|
|
13
21
|
</div>
|
|
22
|
+
{/if}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { DataStateKind } from './DataState.svelte';
|
|
1
2
|
export interface ApiKeySummary {
|
|
2
3
|
id: string;
|
|
3
4
|
name: string;
|
|
@@ -7,6 +8,12 @@ export interface ApiKeySummary {
|
|
|
7
8
|
}
|
|
8
9
|
interface Props {
|
|
9
10
|
keys?: ApiKeySummary[];
|
|
11
|
+
state?: DataStateKind;
|
|
12
|
+
stateTitle?: string;
|
|
13
|
+
stateDescription?: string;
|
|
14
|
+
retry?: () => void;
|
|
15
|
+
retryLabel?: string;
|
|
16
|
+
loadingLabel?: string;
|
|
10
17
|
oncopy?: (key: ApiKeySummary) => void;
|
|
11
18
|
onrevoke?: (key: ApiKeySummary) => void;
|
|
12
19
|
class?: string;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
|
+
import { useTranslation } from '@svadmin/core/i18n';
|
|
3
4
|
import { AlertTriangle, Inbox, LockKeyhole, RefreshCw } from '@lucide/svelte';
|
|
4
5
|
import { Button } from '../ui/button/index.js';
|
|
5
6
|
import * as Alert from '../ui/alert/index.js';
|
|
@@ -10,22 +11,37 @@
|
|
|
10
11
|
title?: string;
|
|
11
12
|
description?: string;
|
|
12
13
|
retry?: () => void;
|
|
14
|
+
retryLabel?: string;
|
|
15
|
+
loadingLabel?: string;
|
|
13
16
|
action?: Snippet;
|
|
14
17
|
class?: string;
|
|
15
18
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
const i18n = useTranslation();
|
|
20
|
+
let {
|
|
21
|
+
state,
|
|
22
|
+
title,
|
|
23
|
+
description,
|
|
24
|
+
retry,
|
|
25
|
+
retryLabel,
|
|
26
|
+
loadingLabel,
|
|
27
|
+
action,
|
|
28
|
+
class: className = '',
|
|
29
|
+
}: Props = $props();
|
|
30
|
+
const isZh = $derived(i18n.locale === 'zh-CN');
|
|
31
|
+
const defaults = $derived({
|
|
32
|
+
loading: [i18n.t('common.loading'), i18n.t('common.loading')],
|
|
33
|
+
empty: [i18n.t('empty.title'), i18n.t('empty.description')],
|
|
34
|
+
error: [i18n.t('common.error'), i18n.t('common.retry')],
|
|
35
|
+
forbidden: [isZh ? '无权访问' : 'Access restricted', isZh ? '你没有查看此内容的权限。' : 'You do not have permission to view this content.'],
|
|
36
|
+
} as const);
|
|
23
37
|
const resolvedTitle = $derived(title ?? defaults[state][0]);
|
|
24
38
|
const resolvedDescription = $derived(description ?? defaults[state][1]);
|
|
39
|
+
const resolvedRetryLabel = $derived(retryLabel ?? i18n.t('common.retry'));
|
|
40
|
+
const resolvedLoadingLabel = $derived(loadingLabel ?? i18n.t('common.loading'));
|
|
25
41
|
</script>
|
|
26
42
|
|
|
27
43
|
{#if state === 'loading'}
|
|
28
|
-
<div class={'space-y-3 rounded-lg border border-border bg-card p-5 ' + className} aria-busy="true">
|
|
44
|
+
<div class={'space-y-3 rounded-lg border border-border bg-card p-5 ' + className} role="status" aria-live="polite" aria-label={resolvedLoadingLabel} aria-busy="true">
|
|
29
45
|
<Skeleton class="h-5 w-40" /><Skeleton class="h-4 w-full max-w-md" /><Skeleton class="h-20 w-full" />
|
|
30
46
|
</div>
|
|
31
47
|
{:else if state === 'error' || state === 'forbidden'}
|
|
@@ -33,7 +49,7 @@
|
|
|
33
49
|
{#if state === 'error'}<AlertTriangle class="size-4" />{:else}<LockKeyhole class="size-4" />{/if}
|
|
34
50
|
<Alert.Title>{resolvedTitle}</Alert.Title>
|
|
35
51
|
<Alert.Description>{resolvedDescription}</Alert.Description>
|
|
36
|
-
{#if retry}<Button variant="outline" size="sm" class="mt-3" onclick={retry}><RefreshCw class="size-3.5" />
|
|
52
|
+
{#if retry}<Button variant="outline" size="sm" class="mt-3" onclick={retry}><RefreshCw class="size-3.5" />{resolvedRetryLabel}</Button>{/if}
|
|
37
53
|
{#if action}<div class="mt-3">{@render action()}</div>{/if}
|
|
38
54
|
</Alert.Root>
|
|
39
55
|
{:else}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { CircleAlert, Info, TriangleAlert } from '@lucide/svelte';
|
|
4
|
+
import { cn } from '../../utils.js';
|
|
5
|
+
|
|
6
|
+
export type FeedbackNoticeTone = 'info' | 'warning' | 'danger';
|
|
7
|
+
export type FeedbackNoticePriority = 'contextual' | 'blocking';
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
message: string;
|
|
11
|
+
tone?: FeedbackNoticeTone;
|
|
12
|
+
priority?: FeedbackNoticePriority;
|
|
13
|
+
action?: Snippet;
|
|
14
|
+
class?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let {
|
|
18
|
+
message,
|
|
19
|
+
tone = 'info',
|
|
20
|
+
priority = 'contextual',
|
|
21
|
+
action,
|
|
22
|
+
class: className = '',
|
|
23
|
+
}: Props = $props();
|
|
24
|
+
|
|
25
|
+
const isBlocking = $derived(priority === 'blocking' || tone === 'danger');
|
|
26
|
+
const toneClass = $derived(
|
|
27
|
+
tone === 'danger'
|
|
28
|
+
? 'border-destructive/40 bg-destructive/5 text-destructive'
|
|
29
|
+
: tone === 'warning'
|
|
30
|
+
? 'border-warning/50 bg-warning/10 text-warning-foreground'
|
|
31
|
+
: 'border-border bg-muted/40 text-foreground',
|
|
32
|
+
);
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<div
|
|
36
|
+
data-svadmin-feedback-notice
|
|
37
|
+
data-tone={tone}
|
|
38
|
+
data-priority={priority}
|
|
39
|
+
role={isBlocking ? 'alert' : 'status'}
|
|
40
|
+
aria-live={isBlocking ? 'assertive' : 'polite'}
|
|
41
|
+
class={cn(
|
|
42
|
+
'flex w-full flex-col gap-3 rounded-md border px-3 py-2.5 text-sm sm:flex-row sm:items-center',
|
|
43
|
+
isBlocking && 'border-l-2',
|
|
44
|
+
toneClass,
|
|
45
|
+
className,
|
|
46
|
+
)}
|
|
47
|
+
>
|
|
48
|
+
<div class="flex min-w-0 flex-1 items-start gap-2.5">
|
|
49
|
+
{#if tone === 'danger'}
|
|
50
|
+
<CircleAlert class="mt-0.5 size-4 shrink-0" />
|
|
51
|
+
{:else if tone === 'warning'}
|
|
52
|
+
<TriangleAlert class="mt-0.5 size-4 shrink-0" />
|
|
53
|
+
{:else}
|
|
54
|
+
<Info class="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
|
55
|
+
{/if}
|
|
56
|
+
<p class="min-w-0 leading-5">{message}</p>
|
|
57
|
+
</div>
|
|
58
|
+
{#if action}
|
|
59
|
+
<div class="shrink-0">{@render action()}</div>
|
|
60
|
+
{/if}
|
|
61
|
+
</div>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
export type FeedbackNoticeTone = 'info' | 'warning' | 'danger';
|
|
3
|
+
export type FeedbackNoticePriority = 'contextual' | 'blocking';
|
|
4
|
+
interface Props {
|
|
5
|
+
message: string;
|
|
6
|
+
tone?: FeedbackNoticeTone;
|
|
7
|
+
priority?: FeedbackNoticePriority;
|
|
8
|
+
action?: Snippet;
|
|
9
|
+
class?: string;
|
|
10
|
+
}
|
|
11
|
+
declare const FeedbackNotice: import("svelte").Component<Props, {}, "">;
|
|
12
|
+
type FeedbackNotice = ReturnType<typeof FeedbackNotice>;
|
|
13
|
+
export default FeedbackNotice;
|
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { Download, File, Trash2 } from '@lucide/svelte';
|
|
3
|
+
import { useTranslation } from '@svadmin/core/i18n';
|
|
3
4
|
import { Button } from '../ui/button/index.js';
|
|
5
|
+
import DataState from './DataState.svelte';
|
|
6
|
+
import type { DataStateKind } from './DataState.svelte';
|
|
4
7
|
export interface FileItem { id?: string; name: string; size?: string; type?: string; updatedAt?: string; }
|
|
5
|
-
interface Props { files?: FileItem[]; ondownload?: (file: FileItem) => void; onremove?: (file: FileItem) => void; class?: string; }
|
|
6
|
-
|
|
8
|
+
interface Props { files?: FileItem[]; state?: DataStateKind; stateTitle?: string; stateDescription?: string; emptyTitle?: string; emptyDescription?: string; retry?: () => void; retryLabel?: string; loadingLabel?: string; ondownload?: (file: FileItem) => void; onremove?: (file: FileItem) => void; class?: string; }
|
|
9
|
+
const i18n = useTranslation();
|
|
10
|
+
let { files = [], state, stateTitle, stateDescription, emptyTitle, emptyDescription, retry, retryLabel, loadingLabel, ondownload, onremove, class: className = '' }: Props = $props();
|
|
11
|
+
const resolvedState = $derived(state ?? (files.length === 0 ? 'empty' : undefined));
|
|
7
12
|
</script>
|
|
13
|
+
{#if resolvedState}
|
|
14
|
+
<DataState state={resolvedState} title={stateTitle ?? emptyTitle} description={stateDescription ?? emptyDescription} {retry} {retryLabel} {loadingLabel} class={className} />
|
|
15
|
+
{:else}
|
|
8
16
|
<div class={'divide-y divide-border overflow-hidden rounded-lg border border-border bg-card ' + className}>
|
|
9
17
|
{#each files as file, index (file.id ?? file.name + '-' + index)}
|
|
10
|
-
<div class="flex items-center gap-3 px-4 py-3"><span class="flex size-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground"><File class="size-4" /></span><div class="min-w-0 flex-1"><p class="truncate text-sm font-medium text-foreground">{file.name}</p><p class="text-xs text-muted-foreground">{file.size ?? file.type ?? 'File'}{file.updatedAt ? ' / ' + file.updatedAt : ''}</p></div>{#if ondownload}<Button variant="ghost" size="icon-sm" aria-label={'Download ' + file.name} onclick={() => ondownload?.(file)}><Download class="size-4" /></Button>{/if}{#if onremove}<Button variant="ghost" size="icon-sm" aria-label={'Remove ' + file.name} onclick={() => onremove?.(file)}><Trash2 class="size-4" /></Button>{/if}</div>
|
|
18
|
+
<div class="flex items-center gap-3 px-4 py-3"><span class="flex size-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground"><File class="size-4" /></span><div class="min-w-0 flex-1"><p class="truncate text-sm font-medium text-foreground">{file.name}</p><p class="text-xs text-muted-foreground">{file.size ?? file.type ?? (i18n.locale === 'zh-CN' ? '文件' : 'File')}{file.updatedAt ? ' / ' + file.updatedAt : ''}</p></div>{#if ondownload}<Button variant="ghost" size="icon-sm" aria-label={(i18n.locale === 'zh-CN' ? '下载 ' : 'Download ') + file.name} onclick={() => ondownload?.(file)}><Download class="size-4" /></Button>{/if}{#if onremove}<Button variant="ghost" size="icon-sm" aria-label={(i18n.locale === 'zh-CN' ? '移除 ' : 'Remove ') + file.name} onclick={() => onremove?.(file)}><Trash2 class="size-4" /></Button>{/if}</div>
|
|
11
19
|
{/each}
|
|
12
|
-
{#if files.length === 0}<div class="p-6 text-center text-sm text-muted-foreground">No files</div>{/if}
|
|
13
20
|
</div>
|
|
21
|
+
{/if}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { DataStateKind } from './DataState.svelte';
|
|
1
2
|
export interface FileItem {
|
|
2
3
|
id?: string;
|
|
3
4
|
name: string;
|
|
@@ -7,6 +8,14 @@ export interface FileItem {
|
|
|
7
8
|
}
|
|
8
9
|
interface Props {
|
|
9
10
|
files?: FileItem[];
|
|
11
|
+
state?: DataStateKind;
|
|
12
|
+
stateTitle?: string;
|
|
13
|
+
stateDescription?: string;
|
|
14
|
+
emptyTitle?: string;
|
|
15
|
+
emptyDescription?: string;
|
|
16
|
+
retry?: () => void;
|
|
17
|
+
retryLabel?: string;
|
|
18
|
+
loadingLabel?: string;
|
|
10
19
|
ondownload?: (file: FileItem) => void;
|
|
11
20
|
onremove?: (file: FileItem) => void;
|
|
12
21
|
class?: string;
|