@svadmin/ui 0.46.0 → 0.47.1
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/AboutSettings.svelte +21 -96
- package/dist/components/ApiSettings.svelte +54 -234
- package/dist/components/AppearanceSettings.svelte +36 -165
- package/dist/components/AutoForm.svelte +21 -3
- package/dist/components/DetailDrawer.svelte +74 -0
- package/dist/components/DetailDrawer.svelte.d.ts +16 -0
- package/dist/components/FieldRenderer.svelte +70 -2
- package/dist/components/FieldRenderer.svelte.d.ts +2 -0
- package/dist/components/IntegrationsSettings.svelte +22 -61
- package/dist/components/NotificationsSettings.svelte +22 -103
- package/dist/components/PermissionMatrix.svelte +17 -8
- package/dist/components/RowActions.svelte +154 -0
- package/dist/components/RowActions.svelte.d.ts +26 -0
- package/dist/components/SecuritySettings.svelte +18 -58
- package/dist/components/SettingsPage.svelte +12 -2
- package/dist/components/account/CompanyProfilePage.svelte +10 -4
- package/dist/components/account/SettingsEnterprisePage.svelte +1 -1
- package/dist/components/account/UserProfilePage.svelte +5 -4
- package/dist/components/content/ApiKeyList.svelte +30 -5
- package/dist/components/content/ApiKeyList.svelte.d.ts +3 -0
- package/dist/components/content/WorkspaceLayout.svelte +7 -4
- package/dist/components/content/WorkspaceLayout.svelte.d.ts +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/package.json +4 -1
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Ellipsis } from '@lucide/svelte';
|
|
3
|
+
import type { Component, Snippet } from 'svelte';
|
|
4
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
5
|
+
import { cn, type WithElementRef } from '../utils.js';
|
|
6
|
+
import * as DropdownMenu from './ui/dropdown-menu/index.js';
|
|
7
|
+
import { Button } from './ui/button/index.js';
|
|
8
|
+
|
|
9
|
+
export type RowActionItem = {
|
|
10
|
+
label: string;
|
|
11
|
+
icon?: Component<{ class?: string }>;
|
|
12
|
+
href?: string;
|
|
13
|
+
onclick?: (e: MouseEvent) => void;
|
|
14
|
+
variant?: 'default' | 'outline' | 'ghost' | 'destructive' | 'secondary';
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
title?: string;
|
|
17
|
+
danger?: boolean;
|
|
18
|
+
hidden?: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type Props = WithElementRef<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
|
22
|
+
actions?: RowActionItem[];
|
|
23
|
+
maxVisible?: number;
|
|
24
|
+
moreLabel?: string;
|
|
25
|
+
children?: Snippet;
|
|
26
|
+
moreContent?: Snippet;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
let {
|
|
30
|
+
actions = [],
|
|
31
|
+
maxVisible = 2,
|
|
32
|
+
moreLabel = 'More actions',
|
|
33
|
+
ref = $bindable(null),
|
|
34
|
+
class: className,
|
|
35
|
+
children,
|
|
36
|
+
moreContent,
|
|
37
|
+
...restProps
|
|
38
|
+
}: Props = $props();
|
|
39
|
+
|
|
40
|
+
let overflowOpen = $state(false);
|
|
41
|
+
const availableActions = $derived(actions.filter((action) => !action.hidden));
|
|
42
|
+
const visibleLimit = $derived(
|
|
43
|
+
Number.isFinite(maxVisible)
|
|
44
|
+
? Math.max(0, Math.floor(maxVisible))
|
|
45
|
+
: availableActions.length
|
|
46
|
+
);
|
|
47
|
+
const visibleActions = $derived(availableActions.slice(0, visibleLimit));
|
|
48
|
+
const overflowActions = $derived(availableActions.slice(visibleLimit));
|
|
49
|
+
|
|
50
|
+
function resolvedVariant(action: RowActionItem): RowActionItem['variant'] {
|
|
51
|
+
return action.variant ?? (action.danger ? 'destructive' : 'ghost');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function handleOverflowAction(action: RowActionItem, event: MouseEvent): void {
|
|
55
|
+
if (action.disabled) {
|
|
56
|
+
event.preventDefault();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
overflowOpen = false;
|
|
60
|
+
action.onclick?.(event);
|
|
61
|
+
}
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<div
|
|
65
|
+
bind:this={ref}
|
|
66
|
+
data-slot="row-actions"
|
|
67
|
+
class={cn("inline-flex items-center gap-1.5", className)}
|
|
68
|
+
{...restProps}
|
|
69
|
+
>
|
|
70
|
+
{#if children}
|
|
71
|
+
{@render children()}
|
|
72
|
+
{/if}
|
|
73
|
+
|
|
74
|
+
{#each visibleActions as action (action)}
|
|
75
|
+
{#if action.href}
|
|
76
|
+
<Button
|
|
77
|
+
variant={resolvedVariant(action)}
|
|
78
|
+
size="sm"
|
|
79
|
+
href={action.href}
|
|
80
|
+
disabled={action.disabled}
|
|
81
|
+
title={action.title || action.label}
|
|
82
|
+
class="h-8 px-2 text-xs"
|
|
83
|
+
>
|
|
84
|
+
{#if action.icon}
|
|
85
|
+
{@const Icon = action.icon}
|
|
86
|
+
<Icon class="mr-1 size-3.5" />
|
|
87
|
+
{/if}
|
|
88
|
+
{action.label}
|
|
89
|
+
</Button>
|
|
90
|
+
{:else}
|
|
91
|
+
<Button
|
|
92
|
+
variant={resolvedVariant(action)}
|
|
93
|
+
size="sm"
|
|
94
|
+
onclick={action.onclick}
|
|
95
|
+
disabled={action.disabled}
|
|
96
|
+
title={action.title || action.label}
|
|
97
|
+
class="h-8 px-2 text-xs"
|
|
98
|
+
>
|
|
99
|
+
{#if action.icon}
|
|
100
|
+
{@const Icon = action.icon}
|
|
101
|
+
<Icon class="mr-1 size-3.5" />
|
|
102
|
+
{/if}
|
|
103
|
+
{action.label}
|
|
104
|
+
</Button>
|
|
105
|
+
{/if}
|
|
106
|
+
{/each}
|
|
107
|
+
|
|
108
|
+
{#if overflowActions.length > 0 || moreContent}
|
|
109
|
+
<DropdownMenu.Root bind:open={overflowOpen}>
|
|
110
|
+
<DropdownMenu.Trigger>
|
|
111
|
+
{#snippet child({ props })}
|
|
112
|
+
<Button {...props} variant="ghost" size="icon-sm" title={moreLabel} aria-label={moreLabel}>
|
|
113
|
+
<Ellipsis class="size-4" />
|
|
114
|
+
</Button>
|
|
115
|
+
{/snippet}
|
|
116
|
+
</DropdownMenu.Trigger>
|
|
117
|
+
<DropdownMenu.Content align="end" class="min-w-[8rem]">
|
|
118
|
+
{#each overflowActions as action (action)}
|
|
119
|
+
{#if action.href}
|
|
120
|
+
<Button
|
|
121
|
+
href={action.href}
|
|
122
|
+
variant={resolvedVariant(action)}
|
|
123
|
+
size="sm"
|
|
124
|
+
disabled={action.disabled}
|
|
125
|
+
onclick={(event) => handleOverflowAction(action, event)}
|
|
126
|
+
class="h-auto w-full justify-start rounded-md px-2 py-1.5 text-sm font-normal"
|
|
127
|
+
>
|
|
128
|
+
{#if action.icon}
|
|
129
|
+
{@const Icon = action.icon}
|
|
130
|
+
<Icon class="size-3.5" />
|
|
131
|
+
{/if}
|
|
132
|
+
{action.label}
|
|
133
|
+
</Button>
|
|
134
|
+
{:else}
|
|
135
|
+
<DropdownMenu.Item
|
|
136
|
+
onclick={(event) => handleOverflowAction(action, event)}
|
|
137
|
+
disabled={action.disabled}
|
|
138
|
+
destructive={action.danger || action.variant === 'destructive'}
|
|
139
|
+
>
|
|
140
|
+
{#if action.icon}
|
|
141
|
+
{@const Icon = action.icon}
|
|
142
|
+
<Icon class="size-3.5" />
|
|
143
|
+
{/if}
|
|
144
|
+
{action.label}
|
|
145
|
+
</DropdownMenu.Item>
|
|
146
|
+
{/if}
|
|
147
|
+
{/each}
|
|
148
|
+
{#if moreContent}
|
|
149
|
+
{@render moreContent()}
|
|
150
|
+
{/if}
|
|
151
|
+
</DropdownMenu.Content>
|
|
152
|
+
</DropdownMenu.Root>
|
|
153
|
+
{/if}
|
|
154
|
+
</div>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Component, Snippet } from 'svelte';
|
|
2
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
|
+
import { type WithElementRef } from '../utils.js';
|
|
4
|
+
export type RowActionItem = {
|
|
5
|
+
label: string;
|
|
6
|
+
icon?: Component<{
|
|
7
|
+
class?: string;
|
|
8
|
+
}>;
|
|
9
|
+
href?: string;
|
|
10
|
+
onclick?: (e: MouseEvent) => void;
|
|
11
|
+
variant?: 'default' | 'outline' | 'ghost' | 'destructive' | 'secondary';
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
title?: string;
|
|
14
|
+
danger?: boolean;
|
|
15
|
+
hidden?: boolean;
|
|
16
|
+
};
|
|
17
|
+
type Props = WithElementRef<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
|
18
|
+
actions?: RowActionItem[];
|
|
19
|
+
maxVisible?: number;
|
|
20
|
+
moreLabel?: string;
|
|
21
|
+
children?: Snippet;
|
|
22
|
+
moreContent?: Snippet;
|
|
23
|
+
};
|
|
24
|
+
declare const RowActions: Component<Props, {}, "ref">;
|
|
25
|
+
type RowActions = ReturnType<typeof RowActions>;
|
|
26
|
+
export default RowActions;
|
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
import { useNotification } from '@svadmin/core';
|
|
3
3
|
import { useTranslation } from '@svadmin/core/i18n';
|
|
4
4
|
|
|
5
|
-
import { AlertTriangle,
|
|
5
|
+
import { AlertTriangle, Monitor, Smartphone, Trash2 } from '@lucide/svelte';
|
|
6
6
|
import type { Component } from 'svelte';
|
|
7
7
|
import * as Alert from './ui/alert/index.js';
|
|
8
|
-
import * as Card from './ui/card/index.js';
|
|
9
8
|
import { Button } from './ui/button/index.js';
|
|
10
|
-
import { Label } from './ui/label/index.js';
|
|
11
9
|
import { Switch } from './ui/switch/index.js';
|
|
12
10
|
import PasswordInput from './PasswordInput.svelte';
|
|
11
|
+
import SettingsGroup from './content/SettingsGroup.svelte';
|
|
12
|
+
import SettingsFieldRow from './content/SettingsFieldRow.svelte';
|
|
13
|
+
import DataState from './content/DataState.svelte';
|
|
13
14
|
|
|
14
15
|
const i18n = useTranslation();
|
|
15
16
|
const notification = useNotification();
|
|
@@ -76,41 +77,14 @@
|
|
|
76
77
|
<p class="mt-1 text-sm text-muted-foreground">{i18n.t('settings.securityDescription')}</p>
|
|
77
78
|
</div>
|
|
78
79
|
|
|
79
|
-
<
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
{i18n.t('security.twoFactorAuth')}
|
|
88
|
-
</Card.CardTitle>
|
|
89
|
-
<Card.CardDescription>{i18n.t('security.twoFactorDescription')}</Card.CardDescription>
|
|
90
|
-
</Card.CardHeader>
|
|
91
|
-
<Card.CardContent>
|
|
92
|
-
<div class="flex items-center justify-between gap-4 border-t pt-4">
|
|
93
|
-
<div class="space-y-1">
|
|
94
|
-
<Label for="two-factor" class="text-sm font-medium">{i18n.t('security.enable2fa')}</Label>
|
|
95
|
-
<p class="text-xs text-muted-foreground">
|
|
96
|
-
{is2faEnabled ? i18n.t('security.twoFactorActive') : i18n.t('security.twoFactorInactive')}
|
|
97
|
-
</p>
|
|
98
|
-
</div>
|
|
99
|
-
<Switch id="two-factor" bind:checked={is2faEnabled} />
|
|
100
|
-
</div>
|
|
101
|
-
</Card.CardContent>
|
|
102
|
-
</Card.Card>
|
|
103
|
-
|
|
104
|
-
<Card.Card>
|
|
105
|
-
<Card.CardHeader>
|
|
106
|
-
<Card.CardTitle class="flex items-center gap-2 text-base">
|
|
107
|
-
<KeyRound class="h-4 w-4 text-muted-foreground" />
|
|
108
|
-
{i18n.t('profile.changePassword')}
|
|
109
|
-
</Card.CardTitle>
|
|
110
|
-
<Card.CardDescription>{i18n.t('security.passwordLastChanged', { time: lastChanged })}</Card.CardDescription>
|
|
111
|
-
</Card.CardHeader>
|
|
112
|
-
<Card.CardContent>
|
|
113
|
-
<form onsubmit={handlePasswordChange} class="max-w-sm space-y-4">
|
|
80
|
+
<SettingsGroup title={i18n.t('security.twoFactorAuth')} description={i18n.t('security.twoFactorDescription')}>
|
|
81
|
+
<SettingsFieldRow label={i18n.t('security.enable2fa')} description={is2faEnabled ? i18n.t('security.twoFactorActive') : i18n.t('security.twoFactorInactive')}>
|
|
82
|
+
{#snippet control()}<Switch id="two-factor" bind:checked={is2faEnabled} />{/snippet}
|
|
83
|
+
</SettingsFieldRow>
|
|
84
|
+
</SettingsGroup>
|
|
85
|
+
|
|
86
|
+
<SettingsGroup title={i18n.t('profile.changePassword')} description={i18n.t('security.passwordLastChanged', { time: lastChanged })}>
|
|
87
|
+
<form onsubmit={handlePasswordChange} class="max-w-lg space-y-4">
|
|
114
88
|
{#if passwordErrorMessage}
|
|
115
89
|
<Alert.Root variant="destructive">
|
|
116
90
|
<AlertTriangle class="h-4 w-4" />
|
|
@@ -124,23 +98,10 @@
|
|
|
124
98
|
|
|
125
99
|
<Button type="submit">{i18n.t('profile.updatePassword')}</Button>
|
|
126
100
|
</form>
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
<Card.CardHeader class="flex flex-row items-start justify-between gap-4">
|
|
132
|
-
<div class="space-y-1">
|
|
133
|
-
<Card.CardTitle class="flex items-center gap-2 text-base">
|
|
134
|
-
<Shield class="h-4 w-4 text-muted-foreground" />
|
|
135
|
-
{i18n.t('security.activeSessions')}
|
|
136
|
-
</Card.CardTitle>
|
|
137
|
-
<Card.CardDescription>{i18n.t('security.sessionsDescription')}</Card.CardDescription>
|
|
138
|
-
</div>
|
|
139
|
-
{#if sessions.length > 1}
|
|
140
|
-
<Button variant="outline" size="sm" onclick={revokeAllOthers}>{i18n.t('security.revokeOthers')}</Button>
|
|
141
|
-
{/if}
|
|
142
|
-
</Card.CardHeader>
|
|
143
|
-
<Card.CardContent>
|
|
101
|
+
</SettingsGroup>
|
|
102
|
+
|
|
103
|
+
<SettingsGroup title={i18n.t('security.activeSessions')} description={i18n.t('security.sessionsDescription')}>
|
|
104
|
+
{#snippet actions()}{#if sessions.length > 1}<Button variant="outline" size="sm" onclick={revokeAllOthers}>{i18n.t('security.revokeOthers')}</Button>{/if}{/snippet}
|
|
144
105
|
<div class="divide-y">
|
|
145
106
|
{#each sessions as session (session.id)}
|
|
146
107
|
<div class="flex items-center justify-between gap-4 py-3">
|
|
@@ -168,9 +129,8 @@
|
|
|
168
129
|
{/if}
|
|
169
130
|
</div>
|
|
170
131
|
{:else}
|
|
171
|
-
<
|
|
132
|
+
<DataState state="empty" title={i18n.t('security.noSessions')} />
|
|
172
133
|
{/each}
|
|
173
134
|
</div>
|
|
174
|
-
|
|
175
|
-
</Card.Card>
|
|
135
|
+
</SettingsGroup>
|
|
176
136
|
</div>
|
|
@@ -115,6 +115,16 @@
|
|
|
115
115
|
return content ? alias : "profile";
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
+
const contentWidth = $derived(
|
|
119
|
+
activeKey === 'roles' || activeKey === 'audit'
|
|
120
|
+
? 'max-w-[92rem]'
|
|
121
|
+
: activeKey === 'api'
|
|
122
|
+
? 'max-w-[78rem]'
|
|
123
|
+
: activeKey === 'integrations'
|
|
124
|
+
? 'max-w-[64rem]'
|
|
125
|
+
: 'max-w-3xl',
|
|
126
|
+
);
|
|
127
|
+
|
|
118
128
|
// Default redirect to profile
|
|
119
129
|
$effect(() => {
|
|
120
130
|
if (getRoute() === "/settings") {
|
|
@@ -151,7 +161,7 @@
|
|
|
151
161
|
</div>
|
|
152
162
|
{#each resolvedSections as section, _i (_i)}
|
|
153
163
|
<div>
|
|
154
|
-
<h3 class="px-3 mb-1 text-
|
|
164
|
+
<h3 class="px-3 mb-1 text-xs font-medium text-muted-foreground">
|
|
155
165
|
{i18n.t(section.group)}
|
|
156
166
|
</h3>
|
|
157
167
|
<div class="space-y-0.5">
|
|
@@ -175,7 +185,7 @@
|
|
|
175
185
|
</nav>
|
|
176
186
|
|
|
177
187
|
<!-- Right content area -->
|
|
178
|
-
<div class="flex-1 p-6 lg:p-8 {
|
|
188
|
+
<div class="min-w-0 w-full flex-1 p-6 lg:p-8 {contentWidth}">
|
|
179
189
|
{#if activeKey === "profile"}
|
|
180
190
|
{#if profile}{@render profile()}{:else}<ProfilePage />{/if}
|
|
181
191
|
{:else if activeKey === "appearance"}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import MemberList from '../content/MemberList.svelte';
|
|
12
12
|
import SectionHeader from '../content/SectionHeader.svelte';
|
|
13
13
|
import SettingsGroup from '../content/SettingsGroup.svelte';
|
|
14
|
+
import WorkspaceLayout from '../content/WorkspaceLayout.svelte';
|
|
14
15
|
import type { MemberSummary } from '../content/MemberList.svelte';
|
|
15
16
|
const i18n = useTranslation();
|
|
16
17
|
let companyName = $state('Acme Corporation');
|
|
@@ -31,11 +32,16 @@
|
|
|
31
32
|
<Button onclick={save} disabled={saving} size="sm">{#if saving}<Loader2 class="size-3.5 animate-spin" />{/if}{i18n.t('common.save')}</Button>
|
|
32
33
|
{/snippet}
|
|
33
34
|
|
|
35
|
+
{#snippet primary()}
|
|
36
|
+
<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>
|
|
37
|
+
{/snippet}
|
|
38
|
+
|
|
39
|
+
{#snippet secondary()}
|
|
40
|
+
<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>
|
|
41
|
+
{/snippet}
|
|
42
|
+
|
|
34
43
|
<ContentPageShell pageId="account-company-profile" width="wide">
|
|
35
44
|
<ContentPageHeader title={i18n.t('account.companyProfile')} description={i18n.t('account.companyProfileDescription')} actions={headerActions} />
|
|
36
|
-
<
|
|
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>
|
|
39
|
-
</div>
|
|
45
|
+
<WorkspaceLayout {primary} {secondary} secondaryWidth="20rem" />
|
|
40
46
|
<section class="space-y-3"><SectionHeader title={i18n.t('profileSections.members')} /><MemberList {members} emptyTitle={i18n.t('common.noData')} emptyDescription={i18n.t('empty.description')} /></section>
|
|
41
47
|
</ContentPageShell>
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
|
|
31
31
|
<ContentPageShell pageId="account-settings-enterprise" width="wide">
|
|
32
32
|
<ContentPageHeader title={i18n.t('account.settingsEnterprise')} description={i18n.t('account.settingsEnterpriseDescription')} />
|
|
33
|
-
<div class="grid gap-4 lg:grid-cols-2">
|
|
33
|
+
<div class="grid items-start gap-4 lg:grid-cols-2">
|
|
34
34
|
<SettingsGroup title={i18n.t('account.securityPolicy')} description="Set organization-wide controls for authentication and sessions." bodyClass="space-y-4">
|
|
35
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
36
|
<SettingsFieldRow label={i18n.t('account.auditLogging')} description="Preserve an auditable record of organization-level changes." control={auditControl} separated />
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import DescriptionList from '../content/DescriptionList.svelte';
|
|
10
10
|
import FileList from '../content/FileList.svelte';
|
|
11
11
|
import IntegrationCard from '../content/IntegrationCard.svelte';
|
|
12
|
+
import WorkspaceLayout from '../content/WorkspaceLayout.svelte';
|
|
12
13
|
import type { IntegrationSummary } from '../content/IntegrationCard.svelte';
|
|
13
14
|
import { referenceDemoData } from '../../reference-data.js';
|
|
14
15
|
const i18n = useTranslation();
|
|
@@ -22,8 +23,8 @@
|
|
|
22
23
|
|
|
23
24
|
<ContentPageShell pageId="account-user-profile" width="wide">
|
|
24
25
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"><ContentPageHeader title={i18n.t('account.userProfile')} description={i18n.t('account.userProfileDescription')} /><Button onclick={save} disabled={saving} size="sm">{#if saving}<Loader2 class="size-3.5 animate-spin" />{/if}{i18n.t('common.save')}</Button></div>
|
|
25
|
-
<
|
|
26
|
-
<div class="space-y-4"><Card.Card><Card.CardContent class="space-y-5 p-5"><div><h2 class="text-sm font-semibold text-foreground">{i18n.t('account.personalInfo')}</h2><p class="mt-1 text-sm text-muted-foreground">Keep your contact and working details current.</p></div><DescriptionList columns={2} items={[{ label: i18n.t('profile.name'), value: 'Alex Chen' }, { label: i18n.t('account.phone'), value: '+86 138 **** 9527' }, { label: i18n.t('account.dateOfBirth'), value: '1995-04-18' }, { label: i18n.t('account.address'), value: 'Shanghai, CN' }]} /></Card.CardContent></Card.Card><Card.Card><Card.CardContent class="space-y-4 p-5"><div class="flex items-center justify-between gap-3"><div><h2 class="text-sm font-semibold text-foreground">{i18n.t('account.work')}</h2><p class="mt-1 text-sm text-muted-foreground">{i18n.t('account.availableNow')}</p></div><Switch bind:checked={availableToHire} /></div><div class="flex flex-wrap gap-1.5">{#each ['TypeScript', 'Svelte', 'Node.js', 'Design Systems'] as skill (skill)}<span class="rounded-md border border-border px-2 py-1 text-xs text-muted-foreground">{skill}</span>{/each}</div></Card.CardContent></Card.Card><section class="space-y-3"><h2 class="text-base font-semibold text-foreground">{i18n.t('account.connections')}</h2>{#each integrations as integration (integration.id)}<IntegrationCard {integration} onconnect={() => toggle(integration.id)} />{/each}</section></div>
|
|
27
|
-
<div class="space-y-4"><Card.Card><Card.CardContent class="space-y-3 p-5"><h2 class="text-sm font-semibold text-foreground">{i18n.t('account.myFiles')}</h2><FileList files={files} /></Card.CardContent></Card.Card><Card.Card><Card.CardContent class="space-y-3 p-5"><h2 class="text-sm font-semibold text-foreground">{i18n.t('account.calendarAccounts')}</h2><DescriptionList items={[{ label: 'Work Calendar', value: 'alex@nebulalabs.io' }, { label: 'Personal Calendar', value: 'alex@example.com' }]} /></Card.CardContent></Card.Card></div>
|
|
28
|
-
</
|
|
26
|
+
<WorkspaceLayout secondaryWidth="20rem">
|
|
27
|
+
{#snippet primary()}<div class="space-y-4"><Card.Card><Card.CardContent class="space-y-5 p-5"><div><h2 class="text-sm font-semibold text-foreground">{i18n.t('account.personalInfo')}</h2><p class="mt-1 text-sm text-muted-foreground">Keep your contact and working details current.</p></div><DescriptionList columns={2} items={[{ label: i18n.t('profile.name'), value: 'Alex Chen' }, { label: i18n.t('account.phone'), value: '+86 138 **** 9527' }, { label: i18n.t('account.dateOfBirth'), value: '1995-04-18' }, { label: i18n.t('account.address'), value: 'Shanghai, CN' }]} /></Card.CardContent></Card.Card><Card.Card><Card.CardContent class="space-y-4 p-5"><div class="flex items-center justify-between gap-3"><div><h2 class="text-sm font-semibold text-foreground">{i18n.t('account.work')}</h2><p class="mt-1 text-sm text-muted-foreground">{i18n.t('account.availableNow')}</p></div><Switch bind:checked={availableToHire} /></div><div class="flex flex-wrap gap-1.5">{#each ['TypeScript', 'Svelte', 'Node.js', 'Design Systems'] as skill (skill)}<span class="rounded-md border border-border px-2 py-1 text-xs text-muted-foreground">{skill}</span>{/each}</div></Card.CardContent></Card.Card><section class="space-y-3"><h2 class="text-base font-semibold text-foreground">{i18n.t('account.connections')}</h2>{#each integrations as integration (integration.id)}<IntegrationCard {integration} onconnect={() => toggle(integration.id)} />{/each}</section></div>{/snippet}
|
|
28
|
+
{#snippet secondary()}<div class="space-y-4"><Card.Card><Card.CardContent class="space-y-3 p-5"><h2 class="text-sm font-semibold text-foreground">{i18n.t('account.myFiles')}</h2><FileList files={files} /></Card.CardContent></Card.Card><Card.Card><Card.CardContent class="space-y-3 p-5"><h2 class="text-sm font-semibold text-foreground">{i18n.t('account.calendarAccounts')}</h2><DescriptionList items={[{ label: 'Work Calendar', value: 'alex@nebulalabs.io' }, { label: 'Personal Calendar', value: 'alex@example.com' }]} /></Card.CardContent></Card.Card></div>{/snippet}
|
|
29
|
+
</WorkspaceLayout>
|
|
29
30
|
</ContentPageShell>
|
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { Copy, KeyRound, Trash2 } from '@lucide/svelte';
|
|
2
|
+
import { Check, Copy, KeyRound, Trash2 } from '@lucide/svelte';
|
|
3
3
|
import { useTranslation } from '@svadmin/core/i18n';
|
|
4
|
+
import { Badge } from '../ui/badge/index.js';
|
|
4
5
|
import { Button } from '../ui/button/index.js';
|
|
5
6
|
import DataState from './DataState.svelte';
|
|
6
7
|
import type { DataStateKind } from './DataState.svelte';
|
|
7
|
-
export interface ApiKeySummary {
|
|
8
|
-
|
|
8
|
+
export interface ApiKeySummary {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
prefix: string;
|
|
12
|
+
maskedToken?: string;
|
|
13
|
+
createdAt?: string;
|
|
14
|
+
lastUsedAt?: string;
|
|
15
|
+
permissions?: string[];
|
|
16
|
+
}
|
|
17
|
+
interface Props { keys?: ApiKeySummary[]; state?: DataStateKind; stateTitle?: string; stateDescription?: string; retry?: () => void; retryLabel?: string; loadingLabel?: string; copiedKeyId?: string | null; oncopy?: (key: ApiKeySummary) => void; onrevoke?: (key: ApiKeySummary) => void; class?: string; }
|
|
9
18
|
const i18n = useTranslation();
|
|
10
|
-
let { keys = [], state, stateTitle, stateDescription, retry, retryLabel, loadingLabel, oncopy, onrevoke, class: className = '' }: Props = $props();
|
|
19
|
+
let { keys = [], state, stateTitle, stateDescription, retry, retryLabel, loadingLabel, copiedKeyId = null, oncopy, onrevoke, class: className = '' }: Props = $props();
|
|
11
20
|
const resolvedState = $derived(state ?? (keys.length === 0 ? 'empty' : undefined));
|
|
12
21
|
const isZh = $derived(i18n.locale === 'zh-CN');
|
|
13
22
|
</script>
|
|
@@ -16,7 +25,23 @@
|
|
|
16
25
|
{:else}
|
|
17
26
|
<div class={'divide-y divide-border overflow-hidden rounded-lg border border-border bg-card ' + className}>
|
|
18
27
|
{#each keys as apiKey (apiKey.id)}
|
|
19
|
-
<div class="flex flex-wrap items-center gap-3 px-4 py-3"
|
|
28
|
+
<div class="flex flex-wrap items-center gap-3 px-4 py-3">
|
|
29
|
+
<span class="flex size-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary"><KeyRound class="size-4" /></span>
|
|
30
|
+
<div class="min-w-48 flex-1">
|
|
31
|
+
<p class="truncate text-sm font-medium text-foreground">{apiKey.name}</p>
|
|
32
|
+
<p class="truncate font-mono text-xs text-muted-foreground">{apiKey.maskedToken ?? `${apiKey.prefix}********`}</p>
|
|
33
|
+
{#if apiKey.permissions?.length}
|
|
34
|
+
<div class="mt-2 flex flex-wrap gap-1">
|
|
35
|
+
{#each apiKey.permissions as permission (permission)}<Badge variant="secondary" class="px-2 py-0 text-[10px]">{permission}</Badge>{/each}
|
|
36
|
+
</div>
|
|
37
|
+
{/if}
|
|
38
|
+
</div>
|
|
39
|
+
<div class="text-left text-xs text-muted-foreground sm:text-right"><p>{apiKey.createdAt ?? (isZh ? '最近创建' : 'Created recently')}</p><p>{apiKey.lastUsedAt ?? (isZh ? '从未使用' : 'Never used')}</p></div>
|
|
40
|
+
<div class="ml-auto flex items-center gap-1">
|
|
41
|
+
{#if oncopy}<Button variant="ghost" size="icon-sm" aria-label={i18n.t('common.copy') + ' ' + apiKey.name} onclick={() => oncopy?.(apiKey)}>{#if copiedKeyId === apiKey.id}<Check class="size-4 text-success" />{:else}<Copy class="size-4" />{/if}</Button>{/if}
|
|
42
|
+
{#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}
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
20
45
|
{/each}
|
|
21
46
|
</div>
|
|
22
47
|
{/if}
|
|
@@ -3,8 +3,10 @@ export interface ApiKeySummary {
|
|
|
3
3
|
id: string;
|
|
4
4
|
name: string;
|
|
5
5
|
prefix: string;
|
|
6
|
+
maskedToken?: string;
|
|
6
7
|
createdAt?: string;
|
|
7
8
|
lastUsedAt?: string;
|
|
9
|
+
permissions?: string[];
|
|
8
10
|
}
|
|
9
11
|
interface Props {
|
|
10
12
|
keys?: ApiKeySummary[];
|
|
@@ -14,6 +16,7 @@ interface Props {
|
|
|
14
16
|
retry?: () => void;
|
|
15
17
|
retryLabel?: string;
|
|
16
18
|
loadingLabel?: string;
|
|
19
|
+
copiedKeyId?: string | null;
|
|
17
20
|
oncopy?: (key: ApiKeySummary) => void;
|
|
18
21
|
onrevoke?: (key: ApiKeySummary) => void;
|
|
19
22
|
class?: string;
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
secondary?: Snippet;
|
|
8
8
|
summary?: Snippet;
|
|
9
9
|
secondaryWidth?: string;
|
|
10
|
+
mobileOrder?: 'primary-first' | 'secondary-first';
|
|
10
11
|
class?: string;
|
|
11
12
|
}
|
|
12
13
|
|
|
@@ -15,14 +16,16 @@
|
|
|
15
16
|
secondary,
|
|
16
17
|
summary,
|
|
17
18
|
secondaryWidth = '22rem',
|
|
19
|
+
mobileOrder = 'primary-first',
|
|
18
20
|
class: className = '',
|
|
19
21
|
}: Props = $props();
|
|
20
22
|
</script>
|
|
21
23
|
|
|
22
24
|
<div class={cn('flex flex-col gap-6', className)} data-svadmin-workspace-layout style:--workspace-secondary-width={secondaryWidth}>
|
|
23
|
-
{#if summary}<div
|
|
24
|
-
<div class=
|
|
25
|
-
<
|
|
26
|
-
|
|
25
|
+
{#if summary}<div data-svadmin-workspace-summary>{@render summary()}</div>{/if}
|
|
26
|
+
<div class={cn('grid min-w-0 items-start gap-6 xl:grid-cols-[minmax(0,1fr)_var(--workspace-secondary-width)]', mobileOrder === 'secondary-first' && 'grid-rows-[auto_auto]')}>
|
|
27
|
+
{#if mobileOrder === 'secondary-first' && secondary}<aside class="order-1 min-w-0 xl:order-2">{@render secondary()}</aside>{/if}
|
|
28
|
+
<div class={cn('min-w-0', mobileOrder === 'secondary-first' ? 'order-2 xl:order-1' : 'order-1')} data-svadmin-workspace-primary>{@render primary()}</div>
|
|
29
|
+
{#if mobileOrder !== 'secondary-first' && secondary}<aside class="order-2 min-w-0 xl:order-2">{@render secondary()}</aside>{/if}
|
|
27
30
|
</div>
|
|
28
31
|
</div>
|
package/dist/index.d.ts
CHANGED
|
@@ -194,3 +194,6 @@ export { default as TwoFactorStepper } from './components/content/TwoFactorStepp
|
|
|
194
194
|
export { default as SystemErrorState } from './components/content/SystemErrorState.svelte';
|
|
195
195
|
export { referenceDemoData } from './reference-data.js';
|
|
196
196
|
export type { DemoMember, DemoProject, DemoTeam, DemoIntegration, DemoApiKey, DemoSecurityEvent, DemoNotification } from './reference-data.js';
|
|
197
|
+
export { default as RowActions } from './components/RowActions.svelte';
|
|
198
|
+
export type { RowActionItem } from './components/RowActions.svelte';
|
|
199
|
+
export { default as DetailDrawer } from './components/DetailDrawer.svelte';
|
package/dist/index.js
CHANGED
|
@@ -195,3 +195,5 @@ export { default as OtpInput } from './components/content/OtpInput.svelte';
|
|
|
195
195
|
export { default as TwoFactorStepper } from './components/content/TwoFactorStepper.svelte';
|
|
196
196
|
export { default as SystemErrorState } from './components/content/SystemErrorState.svelte';
|
|
197
197
|
export { referenceDemoData } from './reference-data.js';
|
|
198
|
+
export { default as RowActions } from './components/RowActions.svelte';
|
|
199
|
+
export { default as DetailDrawer } from './components/DetailDrawer.svelte';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@svadmin/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.47.1",
|
|
4
4
|
"description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -96,6 +96,9 @@
|
|
|
96
96
|
"bugs": {
|
|
97
97
|
"url": "https://github.com/vibeunion/svadmin/issues"
|
|
98
98
|
},
|
|
99
|
+
"publishConfig": {
|
|
100
|
+
"access": "public"
|
|
101
|
+
},
|
|
99
102
|
"devDependencies": {
|
|
100
103
|
"@happy-dom/global-registrator": "^20.11.2",
|
|
101
104
|
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|