@svadmin/ui 0.17.0 → 0.19.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/package.json +20 -2
- package/src/components/AboutSettings.svelte +106 -0
- package/src/components/AdminApp.svelte +5 -1
- package/src/components/AppearanceSettings.svelte +183 -0
- package/src/components/AuditLogViewer.svelte +226 -0
- package/src/components/AutoTable.svelte +12 -6
- package/src/components/CanAccess.svelte +1 -1
- package/src/components/DraggableGrid.svelte +90 -0
- package/src/components/FieldRenderer.svelte +4 -0
- package/src/components/MarkdownRenderer.svelte +0 -4
- package/src/components/PermissionMatrix.svelte +190 -0
- package/src/components/ProfilePage.svelte +139 -25
- package/src/components/RolesSettings.svelte +144 -0
- package/src/components/SettingsPage.svelte +113 -0
- package/src/components/Sidebar.svelte +3 -0
- package/src/components/TaskQueueDrawer.svelte +170 -0
- package/src/components/TenantSwitcher.svelte +90 -0
- package/src/components/buttons/CloneButton.svelte +3 -3
- package/src/components/buttons/CreateButton.svelte +3 -3
- package/src/components/buttons/DeleteButton.svelte +3 -3
- package/src/components/buttons/EditButton.svelte +3 -3
- package/src/components/buttons/ExportButton.svelte +3 -3
- package/src/components/buttons/ImportButton.svelte +3 -3
- package/src/components/buttons/ShowButton.svelte +3 -3
- package/src/components/fields/ArrayField.svelte +86 -0
- package/src/index.ts +14 -0
- package/src/markdown-deps.d.ts +21 -0
- package/src/components/stories/BarChart.stories.ts +0 -43
- package/src/components/stories/LineChart.stories.ts +0 -48
- package/src/components/stories/PieChart.stories.ts +0 -41
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@svadmin/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -26,7 +26,25 @@
|
|
|
26
26
|
"bits-ui": "^2.0.0",
|
|
27
27
|
"tailwind-variants": "^3.0.0",
|
|
28
28
|
"clsx": "^2.0.0",
|
|
29
|
-
"tailwind-merge": "^3.0.0"
|
|
29
|
+
"tailwind-merge": "^3.0.0",
|
|
30
|
+
"marked": "^15.0.0",
|
|
31
|
+
"marked-highlight": "^2.2.0",
|
|
32
|
+
"highlight.js": "^11.0.0",
|
|
33
|
+
"isomorphic-dompurify": "^2.0.0"
|
|
34
|
+
},
|
|
35
|
+
"peerDependenciesMeta": {
|
|
36
|
+
"marked": {
|
|
37
|
+
"optional": true
|
|
38
|
+
},
|
|
39
|
+
"marked-highlight": {
|
|
40
|
+
"optional": true
|
|
41
|
+
},
|
|
42
|
+
"highlight.js": {
|
|
43
|
+
"optional": true
|
|
44
|
+
},
|
|
45
|
+
"isomorphic-dompurify": {
|
|
46
|
+
"optional": true
|
|
47
|
+
}
|
|
30
48
|
},
|
|
31
49
|
"dependencies": {
|
|
32
50
|
"@lucide/svelte": "^0.577.0",
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { t } from '@svadmin/core/i18n';
|
|
3
|
+
import { getResources, getDataProviderNames } from '@svadmin/core';
|
|
4
|
+
import { getLocale } from '@svadmin/core/i18n';
|
|
5
|
+
import { getResolvedTheme, getColorTheme } from '@svadmin/core';
|
|
6
|
+
import * as Card from './ui/card/index.js';
|
|
7
|
+
import { Badge } from './ui/badge/index.js';
|
|
8
|
+
import { Info, Database, Globe, Palette, Layers } from 'lucide-svelte';
|
|
9
|
+
|
|
10
|
+
const resources = getResources();
|
|
11
|
+
const providerNames = getDataProviderNames();
|
|
12
|
+
|
|
13
|
+
const version = '__SVADMIN_VERSION__'; // replaced at build time or shown as-is
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<div class="space-y-6">
|
|
17
|
+
<div>
|
|
18
|
+
<h2 class="text-xl font-semibold text-foreground">{t('settings.about')}</h2>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<!-- Version -->
|
|
22
|
+
<Card.Card>
|
|
23
|
+
<Card.CardHeader class="pb-3">
|
|
24
|
+
<Card.CardTitle class="text-base flex items-center gap-2">
|
|
25
|
+
<Info class="h-4 w-4 text-muted-foreground" />
|
|
26
|
+
svadmin
|
|
27
|
+
</Card.CardTitle>
|
|
28
|
+
</Card.CardHeader>
|
|
29
|
+
<Card.CardContent>
|
|
30
|
+
<div class="grid gap-4 text-sm">
|
|
31
|
+
<div class="flex items-center justify-between">
|
|
32
|
+
<span class="text-muted-foreground">{t('settings.version')}</span>
|
|
33
|
+
<Badge variant="secondary" class="font-mono text-xs">{version}</Badge>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
</Card.CardContent>
|
|
37
|
+
</Card.Card>
|
|
38
|
+
|
|
39
|
+
<!-- Registered Resources -->
|
|
40
|
+
<Card.Card>
|
|
41
|
+
<Card.CardHeader class="pb-3">
|
|
42
|
+
<Card.CardTitle class="text-base flex items-center gap-2">
|
|
43
|
+
<Layers class="h-4 w-4 text-muted-foreground" />
|
|
44
|
+
{t('settings.registeredResources')}
|
|
45
|
+
</Card.CardTitle>
|
|
46
|
+
</Card.CardHeader>
|
|
47
|
+
<Card.CardContent>
|
|
48
|
+
<div class="flex flex-wrap gap-2">
|
|
49
|
+
{#each resources as resource}
|
|
50
|
+
<Badge variant="outline" class="font-mono text-xs">{resource.name}</Badge>
|
|
51
|
+
{/each}
|
|
52
|
+
{#if resources.length === 0}
|
|
53
|
+
<p class="text-sm text-muted-foreground">—</p>
|
|
54
|
+
{/if}
|
|
55
|
+
</div>
|
|
56
|
+
</Card.CardContent>
|
|
57
|
+
</Card.Card>
|
|
58
|
+
|
|
59
|
+
<!-- Data Providers -->
|
|
60
|
+
<Card.Card>
|
|
61
|
+
<Card.CardHeader class="pb-3">
|
|
62
|
+
<Card.CardTitle class="text-base flex items-center gap-2">
|
|
63
|
+
<Database class="h-4 w-4 text-muted-foreground" />
|
|
64
|
+
{t('settings.dataProvider')}
|
|
65
|
+
</Card.CardTitle>
|
|
66
|
+
</Card.CardHeader>
|
|
67
|
+
<Card.CardContent>
|
|
68
|
+
<div class="flex flex-wrap gap-2">
|
|
69
|
+
{#each providerNames as name}
|
|
70
|
+
<Badge variant="outline" class="font-mono text-xs">{name}</Badge>
|
|
71
|
+
{/each}
|
|
72
|
+
</div>
|
|
73
|
+
</Card.CardContent>
|
|
74
|
+
</Card.Card>
|
|
75
|
+
|
|
76
|
+
<!-- Current Environment -->
|
|
77
|
+
<Card.Card>
|
|
78
|
+
<Card.CardHeader class="pb-3">
|
|
79
|
+
<Card.CardTitle class="text-base flex items-center gap-2">
|
|
80
|
+
<Globe class="h-4 w-4 text-muted-foreground" />
|
|
81
|
+
{t('settings.currentEnvironment')}
|
|
82
|
+
</Card.CardTitle>
|
|
83
|
+
</Card.CardHeader>
|
|
84
|
+
<Card.CardContent>
|
|
85
|
+
<div class="grid gap-3 text-sm">
|
|
86
|
+
<div class="flex items-center justify-between">
|
|
87
|
+
<span class="text-muted-foreground">{t('settings.language')}</span>
|
|
88
|
+
<span class="font-mono text-xs">{getLocale()}</span>
|
|
89
|
+
</div>
|
|
90
|
+
<div class="h-px bg-border"></div>
|
|
91
|
+
<div class="flex items-center justify-between">
|
|
92
|
+
<span class="text-muted-foreground">{t('settings.themeMode')}</span>
|
|
93
|
+
<span class="font-mono text-xs">{getResolvedTheme()}</span>
|
|
94
|
+
</div>
|
|
95
|
+
<div class="h-px bg-border"></div>
|
|
96
|
+
<div class="flex items-center justify-between">
|
|
97
|
+
<span class="text-muted-foreground flex items-center gap-1.5">
|
|
98
|
+
<Palette class="h-3.5 w-3.5" />
|
|
99
|
+
{t('settings.colorAccent')}
|
|
100
|
+
</span>
|
|
101
|
+
<span class="font-mono text-xs">{getColorTheme()}</span>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
</Card.CardContent>
|
|
105
|
+
</Card.Card>
|
|
106
|
+
</div>
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { setDataProvider, setAuthProvider, setResources, setLocale, setTheme, setRouterProvider, getAuthProvider, createHashRouterProvider, configureTheme } from '@svadmin/core';
|
|
7
7
|
import { t } from '@svadmin/core/i18n';
|
|
8
8
|
import { navigate } from '@svadmin/core/router';
|
|
9
|
+
import { currentPath } from '@svadmin/core/router';
|
|
9
10
|
import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query';
|
|
10
11
|
import { setComponentRegistry, type ComponentRegistry } from '../component-registry.svelte.js';
|
|
11
12
|
import Layout from './Layout.svelte';
|
|
@@ -21,6 +22,7 @@
|
|
|
21
22
|
import DevTools from './DevTools.svelte';
|
|
22
23
|
import Sidebar from './Sidebar.svelte';
|
|
23
24
|
import Header from './Header.svelte';
|
|
25
|
+
import SettingsPage from './SettingsPage.svelte';
|
|
24
26
|
import { Button } from './ui/button/index.js';
|
|
25
27
|
import { Input } from './ui/input/index.js';
|
|
26
28
|
import { Badge } from './ui/badge/index.js';
|
|
@@ -149,7 +151,9 @@
|
|
|
149
151
|
<Layout {title} {menu}>
|
|
150
152
|
{#key route + (params.resource ?? '') + (params.id ?? '')}
|
|
151
153
|
<div class="svadmin-page-enter">
|
|
152
|
-
{#if
|
|
154
|
+
{#if currentPath().startsWith('/settings')}
|
|
155
|
+
<SettingsPage />
|
|
156
|
+
{:else if route === '/' || route === ''}
|
|
153
157
|
{#if dashboard}
|
|
154
158
|
{@render dashboard()}
|
|
155
159
|
{:else}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { t } from '@svadmin/core/i18n';
|
|
3
|
+
import { getTheme, setTheme, getResolvedTheme, colorThemes, getColorTheme, setColorTheme, getColorPresets } from '@svadmin/core';
|
|
4
|
+
import { getLocale, setLocale, getAvailableLocales } from '@svadmin/core/i18n';
|
|
5
|
+
import * as Card from './ui/card/index.js';
|
|
6
|
+
import { Button } from './ui/button/index.js';
|
|
7
|
+
import { Sun, Moon, Monitor, Check } from 'lucide-svelte';
|
|
8
|
+
|
|
9
|
+
// Theme mode
|
|
10
|
+
const themeOptions = [
|
|
11
|
+
{ value: 'light' as const, icon: Sun, label: 'settings.light' },
|
|
12
|
+
{ value: 'dark' as const, icon: Moon, label: 'settings.dark' },
|
|
13
|
+
{ value: 'system' as const, icon: Monitor, label: 'settings.systemTheme' },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
let currentTheme = $derived(getTheme());
|
|
17
|
+
let currentColor = $derived(getColorTheme());
|
|
18
|
+
let currentLocale = $derived(getLocale());
|
|
19
|
+
const availableLocales = getAvailableLocales();
|
|
20
|
+
const presets = getColorPresets();
|
|
21
|
+
|
|
22
|
+
// Sidebar density
|
|
23
|
+
const DENSITY_KEY = 'svadmin-sidebar-density';
|
|
24
|
+
let density = $state<'compact' | 'standard'>(
|
|
25
|
+
(typeof localStorage !== 'undefined' ? localStorage.getItem(DENSITY_KEY) : null) as 'compact' | 'standard' ?? 'standard'
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
function setDensity(d: 'compact' | 'standard') {
|
|
29
|
+
density = d;
|
|
30
|
+
if (typeof localStorage !== 'undefined') localStorage.setItem(DENSITY_KEY, d);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Page size
|
|
34
|
+
const PAGE_SIZE_KEY = 'svadmin-default-page-size';
|
|
35
|
+
const pageSizeOptions = [10, 20, 50];
|
|
36
|
+
let pageSize = $state<number>(
|
|
37
|
+
typeof localStorage !== 'undefined' ? parseInt(localStorage.getItem(PAGE_SIZE_KEY) ?? '10', 10) : 10
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
function setPageSize(size: number) {
|
|
41
|
+
pageSize = size;
|
|
42
|
+
if (typeof localStorage !== 'undefined') localStorage.setItem(PAGE_SIZE_KEY, String(size));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Locale display names
|
|
46
|
+
const localeNames: Record<string, string> = {
|
|
47
|
+
'zh-CN': '中文(简体)',
|
|
48
|
+
'en': 'English',
|
|
49
|
+
'ja': '日本語',
|
|
50
|
+
'ko': '한국어',
|
|
51
|
+
};
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<div class="space-y-6">
|
|
55
|
+
<div>
|
|
56
|
+
<h2 class="text-xl font-semibold text-foreground">{t('settings.appearance')}</h2>
|
|
57
|
+
<p class="text-sm text-muted-foreground mt-1">{t('settings.settingsDescription')}</p>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<!-- Theme Mode -->
|
|
61
|
+
<Card.Card>
|
|
62
|
+
<Card.CardHeader class="pb-3">
|
|
63
|
+
<Card.CardTitle class="text-base">{t('settings.themeMode')}</Card.CardTitle>
|
|
64
|
+
</Card.CardHeader>
|
|
65
|
+
<Card.CardContent>
|
|
66
|
+
<div class="grid grid-cols-3 gap-3">
|
|
67
|
+
{#each themeOptions as opt}
|
|
68
|
+
{@const active = currentTheme === opt.value}
|
|
69
|
+
<button
|
|
70
|
+
class="relative flex flex-col items-center gap-2 rounded-xl border-2 p-4 transition-all duration-200
|
|
71
|
+
{active
|
|
72
|
+
? 'border-primary bg-primary/5 shadow-sm'
|
|
73
|
+
: 'border-border hover:border-muted-foreground/30 hover:bg-accent/50'}"
|
|
74
|
+
onclick={() => setTheme(opt.value)}
|
|
75
|
+
>
|
|
76
|
+
{#if active}
|
|
77
|
+
<div class="absolute top-2 right-2">
|
|
78
|
+
<Check class="h-3.5 w-3.5 text-primary" />
|
|
79
|
+
</div>
|
|
80
|
+
{/if}
|
|
81
|
+
<div class="h-10 w-10 rounded-lg flex items-center justify-center
|
|
82
|
+
{active ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}">
|
|
83
|
+
<opt.icon class="h-5 w-5" />
|
|
84
|
+
</div>
|
|
85
|
+
<span class="text-xs font-medium {active ? 'text-primary' : 'text-muted-foreground'}">
|
|
86
|
+
{t(opt.label)}
|
|
87
|
+
</span>
|
|
88
|
+
</button>
|
|
89
|
+
{/each}
|
|
90
|
+
</div>
|
|
91
|
+
</Card.CardContent>
|
|
92
|
+
</Card.Card>
|
|
93
|
+
|
|
94
|
+
<!-- Color Accent -->
|
|
95
|
+
<Card.Card>
|
|
96
|
+
<Card.CardHeader class="pb-3">
|
|
97
|
+
<Card.CardTitle class="text-base">{t('settings.colorAccent')}</Card.CardTitle>
|
|
98
|
+
</Card.CardHeader>
|
|
99
|
+
<Card.CardContent>
|
|
100
|
+
<div class="flex flex-wrap gap-3">
|
|
101
|
+
{#each presets as preset}
|
|
102
|
+
{@const active = currentColor === preset.name}
|
|
103
|
+
<button
|
|
104
|
+
class="group relative h-9 w-9 rounded-full transition-all duration-200 hover:scale-110
|
|
105
|
+
{active ? 'ring-2 ring-offset-2 ring-offset-background scale-110' : 'opacity-70 hover:opacity-100'}"
|
|
106
|
+
style="background-color: {preset.color}; {active ? `--tw-ring-color: ${preset.color}` : ''}"
|
|
107
|
+
onclick={() => setColorTheme(preset.name as Parameters<typeof setColorTheme>[0])}
|
|
108
|
+
title={preset.label}
|
|
109
|
+
>
|
|
110
|
+
{#if active}
|
|
111
|
+
<Check class="h-4 w-4 text-white absolute inset-0 m-auto drop-shadow-sm" />
|
|
112
|
+
{/if}
|
|
113
|
+
</button>
|
|
114
|
+
{/each}
|
|
115
|
+
</div>
|
|
116
|
+
</Card.CardContent>
|
|
117
|
+
</Card.Card>
|
|
118
|
+
|
|
119
|
+
<!-- Interface -->
|
|
120
|
+
<Card.Card>
|
|
121
|
+
<Card.CardHeader class="pb-3">
|
|
122
|
+
<Card.CardTitle class="text-base">{t('settings.interface')}</Card.CardTitle>
|
|
123
|
+
</Card.CardHeader>
|
|
124
|
+
<Card.CardContent class="space-y-5">
|
|
125
|
+
<!-- Language -->
|
|
126
|
+
<div class="flex items-center justify-between">
|
|
127
|
+
<div>
|
|
128
|
+
<p class="text-sm font-medium text-foreground">{t('settings.language')}</p>
|
|
129
|
+
<p class="text-xs text-muted-foreground">{localeNames[currentLocale] ?? currentLocale}</p>
|
|
130
|
+
</div>
|
|
131
|
+
<select
|
|
132
|
+
class="h-9 rounded-md border border-input bg-background px-3 text-sm ring-offset-background
|
|
133
|
+
focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
|
134
|
+
value={currentLocale}
|
|
135
|
+
onchange={(e) => setLocale((e.target as HTMLSelectElement).value)}
|
|
136
|
+
>
|
|
137
|
+
{#each availableLocales as loc}
|
|
138
|
+
<option value={loc}>{localeNames[loc] ?? loc}</option>
|
|
139
|
+
{/each}
|
|
140
|
+
</select>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
<div class="h-px bg-border"></div>
|
|
144
|
+
|
|
145
|
+
<!-- Sidebar Density -->
|
|
146
|
+
<div class="flex items-center justify-between">
|
|
147
|
+
<div>
|
|
148
|
+
<p class="text-sm font-medium text-foreground">{t('settings.sidebarDensity')}</p>
|
|
149
|
+
</div>
|
|
150
|
+
<div class="flex rounded-lg border border-input overflow-hidden">
|
|
151
|
+
<button
|
|
152
|
+
class="px-3 py-1.5 text-xs font-medium transition-colors
|
|
153
|
+
{density === 'compact' ? 'bg-primary text-primary-foreground' : 'bg-background text-muted-foreground hover:bg-accent'}"
|
|
154
|
+
onclick={() => setDensity('compact')}
|
|
155
|
+
>{t('settings.compact')}</button>
|
|
156
|
+
<button
|
|
157
|
+
class="px-3 py-1.5 text-xs font-medium transition-colors
|
|
158
|
+
{density === 'standard' ? 'bg-primary text-primary-foreground' : 'bg-background text-muted-foreground hover:bg-accent'}"
|
|
159
|
+
onclick={() => setDensity('standard')}
|
|
160
|
+
>{t('settings.standard')}</button>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
<div class="h-px bg-border"></div>
|
|
165
|
+
|
|
166
|
+
<!-- Default Page Size -->
|
|
167
|
+
<div class="flex items-center justify-between">
|
|
168
|
+
<div>
|
|
169
|
+
<p class="text-sm font-medium text-foreground">{t('settings.defaultPageSize')}</p>
|
|
170
|
+
</div>
|
|
171
|
+
<div class="flex rounded-lg border border-input overflow-hidden">
|
|
172
|
+
{#each pageSizeOptions as size}
|
|
173
|
+
<button
|
|
174
|
+
class="px-3 py-1.5 text-xs font-mono font-medium transition-colors
|
|
175
|
+
{pageSize === size ? 'bg-primary text-primary-foreground' : 'bg-background text-muted-foreground hover:bg-accent'}"
|
|
176
|
+
onclick={() => setPageSize(size)}
|
|
177
|
+
>{size}</button>
|
|
178
|
+
{/each}
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
</Card.CardContent>
|
|
182
|
+
</Card.Card>
|
|
183
|
+
</div>
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { t } from '@svadmin/core/i18n';
|
|
3
|
+
import { getAuthProvider } from '@svadmin/core';
|
|
4
|
+
import type { AuditLog } from '@svadmin/core';
|
|
5
|
+
import { toast } from '@svadmin/core/toast';
|
|
6
|
+
import * as Table from './ui/table/index.js';
|
|
7
|
+
import { Badge } from './ui/badge/index.js';
|
|
8
|
+
import { Button } from './ui/button/index.js';
|
|
9
|
+
import { Input } from './ui/input/index.js';
|
|
10
|
+
import { Loader2, ChevronLeft, ChevronRight, Search, FileSearch, Eye, X } from 'lucide-svelte';
|
|
11
|
+
|
|
12
|
+
const authProvider = getAuthProvider();
|
|
13
|
+
|
|
14
|
+
let logs = $state<AuditLog[]>([]);
|
|
15
|
+
let total = $state(0);
|
|
16
|
+
let page = $state(1);
|
|
17
|
+
let pageSize = 20;
|
|
18
|
+
let loading = $state(true);
|
|
19
|
+
let error = $state<string | null>(null);
|
|
20
|
+
let searchQuery = $state('');
|
|
21
|
+
|
|
22
|
+
// Snapshot drawer
|
|
23
|
+
let drawerOpen = $state(false);
|
|
24
|
+
let drawerLog = $state<AuditLog | null>(null);
|
|
25
|
+
|
|
26
|
+
async function loadLogs() {
|
|
27
|
+
if (!authProvider?.getAuditLogs) {
|
|
28
|
+
error = t('settings.auditNotSupported') ?? 'Audit logs are not supported by the current AuthProvider';
|
|
29
|
+
loading = false;
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
loading = true;
|
|
33
|
+
try {
|
|
34
|
+
const result = await authProvider.getAuditLogs({ page, pageSize });
|
|
35
|
+
logs = result.data;
|
|
36
|
+
total = result.total;
|
|
37
|
+
} catch (e) {
|
|
38
|
+
toast.error((e as Error).message);
|
|
39
|
+
} finally {
|
|
40
|
+
loading = false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
$effect(() => {
|
|
45
|
+
loadLogs();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
let totalPages = $derived(Math.ceil(total / pageSize) || 1);
|
|
49
|
+
|
|
50
|
+
let filteredLogs = $derived(
|
|
51
|
+
searchQuery.trim()
|
|
52
|
+
? logs.filter(l =>
|
|
53
|
+
(l.userName ?? '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
54
|
+
l.action.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
55
|
+
(l.resource ?? '').toLowerCase().includes(searchQuery.toLowerCase())
|
|
56
|
+
)
|
|
57
|
+
: logs
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
function openSnapshot(log: AuditLog) {
|
|
61
|
+
drawerLog = log;
|
|
62
|
+
drawerOpen = true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatDate(d: string | Date) {
|
|
66
|
+
return new Date(d).toLocaleString();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getActionColor(action: string): 'default' | 'secondary' | 'destructive' | 'outline' {
|
|
70
|
+
const a = action.toLowerCase();
|
|
71
|
+
if (a.includes('delete') || a.includes('remove')) return 'destructive';
|
|
72
|
+
if (a.includes('create') || a.includes('add')) return 'default';
|
|
73
|
+
if (a.includes('update') || a.includes('edit')) return 'secondary';
|
|
74
|
+
return 'outline';
|
|
75
|
+
}
|
|
76
|
+
</script>
|
|
77
|
+
|
|
78
|
+
<div class="h-full flex flex-col">
|
|
79
|
+
<!-- Header -->
|
|
80
|
+
<div class="mb-6">
|
|
81
|
+
<h2 class="text-xl font-semibold text-foreground flex items-center gap-2">
|
|
82
|
+
<FileSearch class="h-5 w-5 text-muted-foreground" />
|
|
83
|
+
{t('settings.auditLogs') ?? 'Audit Logs'}
|
|
84
|
+
</h2>
|
|
85
|
+
<p class="text-sm text-muted-foreground mt-1">
|
|
86
|
+
{t('settings.auditDescription') ?? 'Track and review all system operations for compliance and debugging.'}
|
|
87
|
+
</p>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
{#if error}
|
|
91
|
+
<div class="p-6 border border-destructive/20 bg-destructive/5 text-destructive rounded-lg text-center">
|
|
92
|
+
<p>{error}</p>
|
|
93
|
+
</div>
|
|
94
|
+
{:else}
|
|
95
|
+
<!-- Search Bar -->
|
|
96
|
+
<div class="mb-4 relative">
|
|
97
|
+
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
98
|
+
<Input
|
|
99
|
+
bind:value={searchQuery}
|
|
100
|
+
placeholder={t('settings.searchLogs') ?? 'Search by user, action, or resource...'}
|
|
101
|
+
class="pl-9"
|
|
102
|
+
/>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<!-- Table -->
|
|
106
|
+
<div class="flex-1 border rounded-lg bg-card overflow-hidden">
|
|
107
|
+
{#if loading}
|
|
108
|
+
<div class="flex items-center justify-center h-48 text-muted-foreground">
|
|
109
|
+
<Loader2 class="h-5 w-5 animate-spin mr-2" />
|
|
110
|
+
{t('common.loading')}
|
|
111
|
+
</div>
|
|
112
|
+
{:else}
|
|
113
|
+
<div class="overflow-auto">
|
|
114
|
+
<Table.Root>
|
|
115
|
+
<Table.Header>
|
|
116
|
+
<Table.Row>
|
|
117
|
+
<Table.Head class="w-[160px]">{t('settings.auditTime') ?? 'Time'}</Table.Head>
|
|
118
|
+
<Table.Head>{t('settings.auditUser') ?? 'User'}</Table.Head>
|
|
119
|
+
<Table.Head>{t('settings.auditAction') ?? 'Action'}</Table.Head>
|
|
120
|
+
<Table.Head>{t('settings.auditResource') ?? 'Resource'}</Table.Head>
|
|
121
|
+
<Table.Head class="w-[120px]">{t('settings.auditIp') ?? 'IP Address'}</Table.Head>
|
|
122
|
+
<Table.Head class="w-[60px] text-center">{t('common.detail') ?? 'Detail'}</Table.Head>
|
|
123
|
+
</Table.Row>
|
|
124
|
+
</Table.Header>
|
|
125
|
+
<Table.Body>
|
|
126
|
+
{#each filteredLogs as log}
|
|
127
|
+
<Table.Row class="hover:bg-muted/50 transition-colors">
|
|
128
|
+
<Table.Cell class="text-xs text-muted-foreground font-mono">
|
|
129
|
+
{formatDate(log.createdAt)}
|
|
130
|
+
</Table.Cell>
|
|
131
|
+
<Table.Cell class="text-sm font-medium">{log.userName ?? log.userId ?? '—'}</Table.Cell>
|
|
132
|
+
<Table.Cell>
|
|
133
|
+
<Badge variant={getActionColor(log.action)}>{log.action}</Badge>
|
|
134
|
+
</Table.Cell>
|
|
135
|
+
<Table.Cell class="text-sm text-muted-foreground">{log.resource ?? '—'}</Table.Cell>
|
|
136
|
+
<Table.Cell class="text-xs text-muted-foreground font-mono">{log.ipAddress ?? '—'}</Table.Cell>
|
|
137
|
+
<Table.Cell class="text-center">
|
|
138
|
+
{#if log.details}
|
|
139
|
+
<Button variant="ghost" size="icon" class="h-7 w-7" onclick={() => openSnapshot(log)}>
|
|
140
|
+
<Eye class="h-3.5 w-3.5" />
|
|
141
|
+
</Button>
|
|
142
|
+
{/if}
|
|
143
|
+
</Table.Cell>
|
|
144
|
+
</Table.Row>
|
|
145
|
+
{:else}
|
|
146
|
+
<Table.Row>
|
|
147
|
+
<Table.Cell colspan={6} class="h-24 text-center text-muted-foreground">
|
|
148
|
+
{t('common.noData')}
|
|
149
|
+
</Table.Cell>
|
|
150
|
+
</Table.Row>
|
|
151
|
+
{/each}
|
|
152
|
+
</Table.Body>
|
|
153
|
+
</Table.Root>
|
|
154
|
+
</div>
|
|
155
|
+
{/if}
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<!-- Pagination -->
|
|
159
|
+
<div class="mt-4 flex items-center justify-between text-sm text-muted-foreground">
|
|
160
|
+
<span>{total} {t('settings.auditTotal') ?? 'total records'}</span>
|
|
161
|
+
<div class="flex items-center gap-2">
|
|
162
|
+
<Button
|
|
163
|
+
variant="outline" size="sm"
|
|
164
|
+
disabled={page <= 1}
|
|
165
|
+
onclick={() => { page--; loadLogs(); }}
|
|
166
|
+
>
|
|
167
|
+
<ChevronLeft class="h-4 w-4" />
|
|
168
|
+
</Button>
|
|
169
|
+
<span>{page} / {totalPages}</span>
|
|
170
|
+
<Button
|
|
171
|
+
variant="outline" size="sm"
|
|
172
|
+
disabled={page >= totalPages}
|
|
173
|
+
onclick={() => { page++; loadLogs(); }}
|
|
174
|
+
>
|
|
175
|
+
<ChevronRight class="h-4 w-4" />
|
|
176
|
+
</Button>
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
{/if}
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<!-- Snapshot Drawer -->
|
|
183
|
+
{#if drawerOpen && drawerLog}
|
|
184
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
185
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
186
|
+
<div class="fixed inset-0 z-[100] flex justify-end bg-black/40 backdrop-blur-sm" onclick={() => drawerOpen = false}>
|
|
187
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
188
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
189
|
+
<div class="bg-card shadow-2xl w-full max-w-2xl h-full flex flex-col animate-in slide-in-from-right duration-300" onclick={(e) => e.stopPropagation()}>
|
|
190
|
+
<!-- Drawer Header -->
|
|
191
|
+
<div class="p-6 border-b border-border flex items-center justify-between bg-muted/30">
|
|
192
|
+
<div>
|
|
193
|
+
<h2 class="text-lg font-bold text-foreground flex items-center gap-2">
|
|
194
|
+
<FileSearch class="h-5 w-5 text-primary" />
|
|
195
|
+
{t('settings.auditSnapshot') ?? 'Change Snapshot'}
|
|
196
|
+
</h2>
|
|
197
|
+
<p class="text-xs text-muted-foreground mt-1">{formatDate(drawerLog.createdAt)} · {drawerLog.userName ?? drawerLog.userId}</p>
|
|
198
|
+
</div>
|
|
199
|
+
<button onclick={() => drawerOpen = false} class="text-muted-foreground hover:text-foreground transition p-2 rounded-lg hover:bg-muted">
|
|
200
|
+
<X class="w-5 h-5" />
|
|
201
|
+
</button>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
<!-- Meta Tags -->
|
|
205
|
+
<div class="px-6 py-3 border-b border-border flex items-center gap-2 flex-wrap">
|
|
206
|
+
<Badge variant="outline">{drawerLog.action}</Badge>
|
|
207
|
+
{#if drawerLog.resource}
|
|
208
|
+
<Badge variant="secondary">{drawerLog.resource}</Badge>
|
|
209
|
+
{/if}
|
|
210
|
+
{#if drawerLog.ipAddress}
|
|
211
|
+
<Badge variant="outline" class="font-mono text-xs">{drawerLog.ipAddress}</Badge>
|
|
212
|
+
{/if}
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
<!-- JSON Diff Viewer -->
|
|
216
|
+
<div class="flex-1 p-6 overflow-y-auto bg-foreground/[0.03] dark:bg-background/50">
|
|
217
|
+
<pre class="whitespace-pre-wrap text-xs font-mono leading-relaxed text-foreground/80 bg-muted/30 rounded-lg p-4 border border-border">{typeof drawerLog.details === 'object' ? JSON.stringify(drawerLog.details, null, 2) : drawerLog.details}</pre>
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
<!-- Footer -->
|
|
221
|
+
<div class="p-4 border-t border-border bg-muted/20 text-right text-[11px] text-muted-foreground font-medium">
|
|
222
|
+
System audit snapshot · Read-only
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
{/if}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { useList, useDelete, getResource } from '@svadmin/core';
|
|
14
14
|
import type { Pagination as PaginationState, Sort, Filter, FieldDefinition } from '@svadmin/core';
|
|
15
15
|
import { navigate } from '@svadmin/core/router';
|
|
16
|
-
import {
|
|
16
|
+
import { useCan, getAccessControlProvider } from '@svadmin/core';
|
|
17
17
|
import { readURLState, writeURLState } from '@svadmin/core';
|
|
18
18
|
import { t } from '@svadmin/core/i18n';
|
|
19
19
|
import { fade } from 'svelte/transition';
|
|
@@ -77,7 +77,8 @@
|
|
|
77
77
|
const urlState = readURLState();
|
|
78
78
|
|
|
79
79
|
// Snapshot resource values for initial state (untrack to avoid reactive tracking)
|
|
80
|
-
const
|
|
80
|
+
const storedPageSize = typeof localStorage !== 'undefined' ? parseInt(localStorage.getItem('svadmin-default-page-size') ?? '', 10) : NaN;
|
|
81
|
+
const initPageSize = untrack(() => resource.pageSize ?? (isNaN(storedPageSize) ? 10 : storedPageSize));
|
|
81
82
|
const initDefaultSort = untrack(() => resource.defaultSort);
|
|
82
83
|
|
|
83
84
|
let pagination = $state<{ current: number; pageSize: number }>(untrack(() => externalPagination ?? {
|
|
@@ -135,10 +136,15 @@
|
|
|
135
136
|
const deleteMutation = deleteResult.mutation;
|
|
136
137
|
|
|
137
138
|
// ─── Permissions ──────────────────────────────────────────────
|
|
138
|
-
const
|
|
139
|
-
const
|
|
140
|
-
const
|
|
141
|
-
const
|
|
139
|
+
const acEnabled = $derived(!!getAccessControlProvider());
|
|
140
|
+
const canCreatePerm = useCan(() => ({ resource: resourceName, action: 'create', queryOptions: { enabled: acEnabled } }));
|
|
141
|
+
const canEditPerm = useCan(() => ({ resource: resourceName, action: 'edit', queryOptions: { enabled: acEnabled } }));
|
|
142
|
+
const canDeletePerm = useCan(() => ({ resource: resourceName, action: 'delete', queryOptions: { enabled: acEnabled } }));
|
|
143
|
+
const canExportPerm = useCan(() => ({ resource: resourceName, action: 'export', queryOptions: { enabled: acEnabled } }));
|
|
144
|
+
const canCreate = $derived(canCreatePerm.allowed && resource.canCreate !== false);
|
|
145
|
+
const canEdit = $derived(canEditPerm.allowed && resource.canEdit !== false);
|
|
146
|
+
const canDelete = $derived(canDeletePerm.allowed && resource.canDelete !== false);
|
|
147
|
+
const canExport = $derived(canExportPerm.allowed);
|
|
142
148
|
|
|
143
149
|
// ─── TanStack Table state ────────────────────────────────────
|
|
144
150
|
let sorting = $state<SortingState>(untrack(() =>
|