create-astram-app 0.1.3 → 0.2.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/add-frontend.js +107 -0
- package/dist/add-frontend.js.map +1 -0
- package/dist/index.js +122 -25
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/template/frontend/FRONTEND.md +92 -0
- package/template/frontend/_env.example +6 -0
- package/template/frontend/_gitignore +9 -0
- package/template/frontend/eslint.config.js +71 -0
- package/template/frontend/index.html +13 -0
- package/template/frontend/package.json +52 -0
- package/template/frontend/public/astram-logo-full.png +0 -0
- package/template/frontend/public/astram-logo-white.png +0 -0
- package/template/frontend/public/favicon.png +0 -0
- package/template/frontend/src/App.jsx +167 -0
- package/template/frontend/src/auth/AuthContext.jsx +338 -0
- package/template/frontend/src/components/ConfirmDialog.jsx +92 -0
- package/template/frontend/src/components/EntityFormModal.jsx +154 -0
- package/template/frontend/src/components/EntityList.jsx +480 -0
- package/template/frontend/src/components/PageHeader.jsx +54 -0
- package/template/frontend/src/components/access/CollapsibleGroup.jsx +34 -0
- package/template/frontend/src/components/ui/alert-dialog.tsx +175 -0
- package/template/frontend/src/components/ui/alert.tsx +83 -0
- package/template/frontend/src/components/ui/avatar.tsx +51 -0
- package/template/frontend/src/components/ui/badge.tsx +56 -0
- package/template/frontend/src/components/ui/button.tsx +67 -0
- package/template/frontend/src/components/ui/card.tsx +92 -0
- package/template/frontend/src/components/ui/checkbox.tsx +30 -0
- package/template/frontend/src/components/ui/date-field.tsx +47 -0
- package/template/frontend/src/components/ui/dialog.tsx +214 -0
- package/template/frontend/src/components/ui/dropdown-menu.tsx +255 -0
- package/template/frontend/src/components/ui/input-otp.tsx +81 -0
- package/template/frontend/src/components/ui/input.tsx +21 -0
- package/template/frontend/src/components/ui/label.tsx +46 -0
- package/template/frontend/src/components/ui/searchable-select.tsx +459 -0
- package/template/frontend/src/components/ui/select.tsx +199 -0
- package/template/frontend/src/components/ui/separator.tsx +26 -0
- package/template/frontend/src/components/ui/sheet.tsx +139 -0
- package/template/frontend/src/components/ui/skeleton.tsx +13 -0
- package/template/frontend/src/components/ui/sonner.tsx +23 -0
- package/template/frontend/src/components/ui/table.tsx +181 -0
- package/template/frontend/src/components/ui/tooltip.tsx +59 -0
- package/template/frontend/src/config/branding.js +80 -0
- package/template/frontend/src/hooks/useMobile.tsx +21 -0
- package/template/frontend/src/index.css +265 -0
- package/template/frontend/src/lib/utils.js +7 -0
- package/template/frontend/src/main.jsx +27 -0
- package/template/frontend/src/pages/Dashboard.jsx +127 -0
- package/template/frontend/src/pages/ForgotPassword.jsx +104 -0
- package/template/frontend/src/pages/Login.jsx +393 -0
- package/template/frontend/src/pages/ResetPassword.jsx +159 -0
- package/template/frontend/src/pages/access/AccessForm.jsx +416 -0
- package/template/frontend/src/pages/access/AccessPage.jsx +282 -0
- package/template/frontend/src/pages/access/RoleActionsMenu.jsx +42 -0
- package/template/frontend/src/pages/account/AccountLayout.jsx +229 -0
- package/template/frontend/src/pages/account/ChangePasswordPage.jsx +235 -0
- package/template/frontend/src/pages/account/ProfilePage.jsx +122 -0
- package/template/frontend/src/pages/account/ReenrollPage.jsx +98 -0
- package/template/frontend/src/pages/account/TwoFactorPage.jsx +833 -0
- package/template/frontend/src/pages/audit/AuditPage.jsx +268 -0
- package/template/frontend/src/pages/companies/CompaniesPage.jsx +232 -0
- package/template/frontend/src/pages/companies/CompanyForm.jsx +127 -0
- package/template/frontend/src/pages/roles/RoleForm.jsx +99 -0
- package/template/frontend/src/pages/roles/RolesPage.jsx +207 -0
- package/template/frontend/src/pages/users/AssignmentsEditor.jsx +193 -0
- package/template/frontend/src/pages/users/MembershipCard.jsx +303 -0
- package/template/frontend/src/pages/users/PostingRow.jsx +264 -0
- package/template/frontend/src/pages/users/UserForm.jsx +167 -0
- package/template/frontend/src/pages/users/UsersPage.jsx +232 -0
- package/template/frontend/src/platform/httpClient.js +125 -0
- package/template/frontend/src/platform/returnPathStore.js +43 -0
- package/template/frontend/src/platform/sessionStore.js +29 -0
- package/template/frontend/src/platform/shieldTransport.js +172 -0
- package/template/frontend/src/platform/shieldTransport.test.mjs +245 -0
- package/template/frontend/src/shell/AppShell.jsx +237 -0
- package/template/frontend/src/shell/Sidebar.jsx +257 -0
- package/template/frontend/src/shell/nav.js +90 -0
- package/template/frontend/src/theme/ThemeProvider.jsx +14 -0
- package/template/frontend/src/theme/ThemeToggle.jsx +44 -0
- package/template/frontend/tsconfig.json +22 -0
- package/template/frontend/vite.config.js +30 -0
- package/template/server/_env.example +27 -15
- package/template/server/package.json +7 -4
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidebar — the collapsible icon-rail navigation. THEME-ADAPTIVE: a light-gray panel
|
|
3
|
+
* in light mode, deep navy in dark (driven by the `--sidebar*` tokens in index.css),
|
|
4
|
+
* so every surface/border/hover reads correctly in both themes. Desktop rail toggles
|
|
5
|
+
* w-64 ↔ w-14; mobile = drawer (AppShell handles the overlay).
|
|
6
|
+
*
|
|
7
|
+
* All colors come from `sidebar-*` tokens (bg-sidebar, text-sidebar-foreground,
|
|
8
|
+
* border-sidebar-border, bg-sidebar-accent, …) — NO hardcoded `white/x` literals, which
|
|
9
|
+
* only looked right on the old always-dark rail.
|
|
10
|
+
*/
|
|
11
|
+
import { NavLink, useNavigate } from 'react-router-dom';
|
|
12
|
+
import { LogOut, Zap } from 'lucide-react';
|
|
13
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
14
|
+
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
|
15
|
+
import { cn } from '@/lib/utils';
|
|
16
|
+
import { branding } from '@/config/branding';
|
|
17
|
+
import { useAuth } from '@/auth/AuthContext';
|
|
18
|
+
import { NAV_SECTIONS } from './nav';
|
|
19
|
+
|
|
20
|
+
/** Two-letter initials from a display name (fallback 'U'), never a UUID. */
|
|
21
|
+
function initialsOf(name) {
|
|
22
|
+
if (!name) return 'U';
|
|
23
|
+
const clean = name.includes('@') ? name.split('@')[0] : name;
|
|
24
|
+
const parts = clean.trim().split(/[\s._-]+/).filter(Boolean);
|
|
25
|
+
const letters = parts.length >= 2 ? parts[0][0] + parts[1][0] : clean.slice(0, 2);
|
|
26
|
+
return letters.replace(/[^a-zA-Z0-9]/g, '').slice(0, 2).toUpperCase() || 'U';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The signed-in user's profile card (OCA `.dash-profile-card` pattern): a CENTERED,
|
|
31
|
+
* vertical, CLICKABLE card — round avatar over name over role — that navigates to
|
|
32
|
+
* Account → Profile, with a hover tint and a bottom divider. Identity comes from the
|
|
33
|
+
* /auth/me profile (display NAMES, never UUIDs); falls back to the interim login hint.
|
|
34
|
+
* Collapses to just the avatar in the icon-rail (with a tooltip carrying the identity).
|
|
35
|
+
*/
|
|
36
|
+
function UserCard({ collapsed, onNavigate }) {
|
|
37
|
+
const { profile, identityHint } = useAuth();
|
|
38
|
+
const navigate = useNavigate();
|
|
39
|
+
const name = profile?.name || (identityHint ? identityHint.split('@')[0] : 'Signed in');
|
|
40
|
+
const company = profile?.company?.name || '';
|
|
41
|
+
const role = profile?.roles?.[0]?.name || '';
|
|
42
|
+
const initials = initialsOf(profile?.name || identityHint);
|
|
43
|
+
|
|
44
|
+
const goProfile = () => {
|
|
45
|
+
navigate('/account/profile');
|
|
46
|
+
onNavigate?.();
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
if (collapsed) {
|
|
50
|
+
// Rail: just the avatar, tooltip carries the identity; still clicks to the profile.
|
|
51
|
+
return (
|
|
52
|
+
<div className="flex justify-center border-b border-sidebar-border py-3">
|
|
53
|
+
<Tooltip>
|
|
54
|
+
<TooltipTrigger asChild>
|
|
55
|
+
<button className="rounded-lg" aria-label="Account" onClick={goProfile}>
|
|
56
|
+
<Avatar className="size-9 shrink-0 rounded-full ring-1 ring-sidebar-border">
|
|
57
|
+
<AvatarFallback className="rounded-full bg-primary/15 text-[11px] font-semibold text-primary">
|
|
58
|
+
{initials}
|
|
59
|
+
</AvatarFallback>
|
|
60
|
+
</Avatar>
|
|
61
|
+
</button>
|
|
62
|
+
</TooltipTrigger>
|
|
63
|
+
<TooltipContent side="right" className="text-xs">
|
|
64
|
+
<div className="font-semibold">{name}</div>
|
|
65
|
+
{company && <div className="opacity-70">{company}</div>}
|
|
66
|
+
{role && <div className="opacity-70">{role}</div>}
|
|
67
|
+
</TooltipContent>
|
|
68
|
+
</Tooltip>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
onClick={goProfile}
|
|
77
|
+
aria-label="Open profile"
|
|
78
|
+
className="flex w-full flex-col items-center gap-1.5 border-b border-sidebar-border px-3 py-3.5 text-center transition-colors hover:bg-sidebar-accent"
|
|
79
|
+
>
|
|
80
|
+
{/* Big round avatar (OCA 56px). Brand-tinted fallback with the initials. */}
|
|
81
|
+
<Avatar className="size-14 rounded-full ring-1 ring-sidebar-border">
|
|
82
|
+
<AvatarFallback className="rounded-full bg-primary/15 text-base font-semibold text-primary">
|
|
83
|
+
{initials}
|
|
84
|
+
</AvatarFallback>
|
|
85
|
+
</Avatar>
|
|
86
|
+
<span className="mt-0.5 truncate text-sm font-semibold text-sidebar-foreground">{name}</span>
|
|
87
|
+
{company && (
|
|
88
|
+
<span className="truncate text-[11px] text-sidebar-foreground/60">{company}</span>
|
|
89
|
+
)}
|
|
90
|
+
{role && (
|
|
91
|
+
<span className="truncate font-mono text-[0.6rem] uppercase tracking-[0.08em] text-sidebar-foreground/50">
|
|
92
|
+
{role}
|
|
93
|
+
</span>
|
|
94
|
+
)}
|
|
95
|
+
</button>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function BrandTile({ collapsed }) {
|
|
100
|
+
return (
|
|
101
|
+
<div
|
|
102
|
+
className={cn(
|
|
103
|
+
// Logo is CENTER-aligned in both states (collapsed rail + expanded).
|
|
104
|
+
'flex items-center justify-center border-b border-sidebar-border py-3.5',
|
|
105
|
+
collapsed ? 'px-0' : 'px-4',
|
|
106
|
+
)}
|
|
107
|
+
>
|
|
108
|
+
{collapsed ? (
|
|
109
|
+
// The rail is too narrow for the wordmark — show the compact accent mark.
|
|
110
|
+
<div className="accent-glow flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary">
|
|
111
|
+
<Zap className="size-4 text-white" />
|
|
112
|
+
</div>
|
|
113
|
+
) : (
|
|
114
|
+
// Theme-adaptive logo: the COLOR logo on the light rail, the WHITE variant on
|
|
115
|
+
// the dark rail (the sidebar surface now follows the theme).
|
|
116
|
+
<>
|
|
117
|
+
<img src={branding.logo.light} alt={branding.appName} className="h-7 w-auto dark:hidden" />
|
|
118
|
+
<img
|
|
119
|
+
src={branding.logo.dark}
|
|
120
|
+
alt={branding.appName}
|
|
121
|
+
className="hidden h-7 w-auto dark:block"
|
|
122
|
+
/>
|
|
123
|
+
</>
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function Item({ item, collapsed, onNavigate }) {
|
|
130
|
+
const Icon = item.icon;
|
|
131
|
+
// Use NavLink's render-prop CHILDREN form (bulletproof across versions) and apply
|
|
132
|
+
// the active styling to an INNER element — avoids relying on the className-function
|
|
133
|
+
// form (which was rendering as a literal string in this setup).
|
|
134
|
+
const link = (
|
|
135
|
+
// `end` for exact-match items; items with `matchPrefix` (e.g. Account, whose real
|
|
136
|
+
// routes are /account/*) stay active across all their sub-routes.
|
|
137
|
+
<NavLink to={item.path} end={!item.matchPrefix} onClick={onNavigate} className="block">
|
|
138
|
+
{({ isActive }) => (
|
|
139
|
+
<span
|
|
140
|
+
className={cn(
|
|
141
|
+
'group flex min-h-9 items-center rounded-lg text-sm font-medium transition-all',
|
|
142
|
+
collapsed ? 'mx-auto size-9 justify-center' : 'gap-3 px-2.5 py-2',
|
|
143
|
+
isActive
|
|
144
|
+
? 'bg-primary text-white shadow-lg shadow-primary/25'
|
|
145
|
+
: 'text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-foreground',
|
|
146
|
+
)}
|
|
147
|
+
>
|
|
148
|
+
<Icon className="size-4 shrink-0" />
|
|
149
|
+
{!collapsed && <span className="truncate">{item.label}</span>}
|
|
150
|
+
{!collapsed && item.badge && (
|
|
151
|
+
<span
|
|
152
|
+
className={cn(
|
|
153
|
+
'ml-auto rounded border px-1.5 py-px text-[10px] font-semibold tracking-wide',
|
|
154
|
+
// Themed badge — reads on both the light and dark rail.
|
|
155
|
+
isActive
|
|
156
|
+
? 'border-white/30 bg-white/20 text-white'
|
|
157
|
+
: 'border-primary/40 bg-primary/10 text-primary',
|
|
158
|
+
)}
|
|
159
|
+
>
|
|
160
|
+
{item.badge}
|
|
161
|
+
</span>
|
|
162
|
+
)}
|
|
163
|
+
</span>
|
|
164
|
+
)}
|
|
165
|
+
</NavLink>
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
if (!collapsed) return link;
|
|
169
|
+
return (
|
|
170
|
+
<Tooltip>
|
|
171
|
+
<TooltipTrigger asChild>{link}</TooltipTrigger>
|
|
172
|
+
<TooltipContent side="right" className="flex items-center gap-2 text-xs">
|
|
173
|
+
{item.label}
|
|
174
|
+
{item.badge && <span className="opacity-60">{item.badge}</span>}
|
|
175
|
+
</TooltipContent>
|
|
176
|
+
</Tooltip>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export default function Sidebar({ collapsed, onLogout, onNavigate, mobile = false }) {
|
|
181
|
+
const isRail = collapsed && !mobile;
|
|
182
|
+
// Phase E slice 1: `can` (not the `useCan` hook) is used here because it's a plain
|
|
183
|
+
// function applied inside a `.filter()` over a static per-render array — calling a
|
|
184
|
+
// HOOK conditionally per array item would violate the Rules of Hooks. One hook call
|
|
185
|
+
// (`useAuth()`) up front, then `can(item.gate)` per item is safe. An item with no
|
|
186
|
+
// `gate` (Dashboard/Notes/Cross-tenant/Account) is never filtered — only admin nav
|
|
187
|
+
// items declare a gate. This is a UX convenience (hide items the caller can't use);
|
|
188
|
+
// the server's decide() on each page's list route remains the real enforcement.
|
|
189
|
+
const { can } = useAuth();
|
|
190
|
+
const visibleSections = NAV_SECTIONS.map((section) => ({
|
|
191
|
+
...section,
|
|
192
|
+
items: section.items.filter((item) => !item.gate || can(item.gate)),
|
|
193
|
+
})).filter((section) => section.items.length > 0);
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<TooltipProvider delayDuration={0}>
|
|
197
|
+
<div
|
|
198
|
+
className={cn(
|
|
199
|
+
'sidebar-panel flex h-full flex-col transition-[width] duration-200',
|
|
200
|
+
mobile ? 'w-64' : isRail ? 'w-14' : 'w-64',
|
|
201
|
+
)}
|
|
202
|
+
>
|
|
203
|
+
<BrandTile collapsed={isRail} />
|
|
204
|
+
|
|
205
|
+
{/* Signed-in user profile card (clickable → Account → Profile) — collapses to
|
|
206
|
+
just the avatar in the icon-rail. */}
|
|
207
|
+
<UserCard collapsed={isRail} onNavigate={onNavigate} />
|
|
208
|
+
|
|
209
|
+
<nav className="flex-1 space-y-4 overflow-y-auto px-2 py-3">
|
|
210
|
+
{visibleSections.map((section) => (
|
|
211
|
+
<div key={section.label}>
|
|
212
|
+
{!isRail && (
|
|
213
|
+
<p className="mb-1 px-2 text-[10px] font-semibold uppercase tracking-widest text-sidebar-foreground/40">
|
|
214
|
+
{section.label}
|
|
215
|
+
</p>
|
|
216
|
+
)}
|
|
217
|
+
<div className="space-y-0.5">
|
|
218
|
+
{section.items.map((item) => (
|
|
219
|
+
<Item key={item.label} item={item} collapsed={isRail} onNavigate={onNavigate} />
|
|
220
|
+
))}
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
))}
|
|
224
|
+
</nav>
|
|
225
|
+
|
|
226
|
+
{/* Bottom: sign out (replaces the old collapse button — collapse now lives
|
|
227
|
+
in the header beside the breadcrumb). */}
|
|
228
|
+
<div className="border-t border-sidebar-border p-2">
|
|
229
|
+
{(() => {
|
|
230
|
+
const btn = (
|
|
231
|
+
<button
|
|
232
|
+
onClick={onLogout}
|
|
233
|
+
className={cn(
|
|
234
|
+
'flex w-full items-center rounded-lg text-sm font-medium text-sidebar-foreground/60 transition-colors hover:bg-destructive/10 hover:text-destructive',
|
|
235
|
+
isRail ? 'mx-auto size-9 justify-center' : 'gap-3 px-2.5 py-2',
|
|
236
|
+
)}
|
|
237
|
+
aria-label="Sign out"
|
|
238
|
+
>
|
|
239
|
+
<LogOut className="size-4 shrink-0" />
|
|
240
|
+
{!isRail && <span>Sign out</span>}
|
|
241
|
+
</button>
|
|
242
|
+
);
|
|
243
|
+
if (!isRail) return btn;
|
|
244
|
+
return (
|
|
245
|
+
<Tooltip>
|
|
246
|
+
<TooltipTrigger asChild>{btn}</TooltipTrigger>
|
|
247
|
+
<TooltipContent side="right" className="text-xs">
|
|
248
|
+
Sign out
|
|
249
|
+
</TooltipContent>
|
|
250
|
+
</Tooltip>
|
|
251
|
+
);
|
|
252
|
+
})()}
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
</TooltipProvider>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app navigation — a plain static menu (no role/redux coupling). Each item has
|
|
3
|
+
* an icon, label, path, and optional badge. Grouped into labelled sections.
|
|
4
|
+
*
|
|
5
|
+
* `gate` (Phase E slice 1): an optional permission KEY the item is hidden without. The
|
|
6
|
+
* Sidebar filters items via `useCan(item.gate)` — reading the `@astram/sdk` auth-facts
|
|
7
|
+
* projection (a UX convenience: hiding an unusable nav entry; NEVER the security
|
|
8
|
+
* boundary — the server's decide() on the underlying @Classified route is the real
|
|
9
|
+
* enforcement regardless of what the nav shows). Every `gate` value below was VERIFIED
|
|
10
|
+
* against the actual `@Classified({ typeKey, action })` on that page's list route (not
|
|
11
|
+
* guessed):
|
|
12
|
+
* - Companies → `identity:company:read` (identity.controller.ts `GET companies`)
|
|
13
|
+
* - Users → `identity:user:read` (identity.controller.ts `GET users`)
|
|
14
|
+
* - Roles → `identity:role:read` (identity.controller.ts `GET roles`)
|
|
15
|
+
* - Access → `identity:role:read` (grant-admin.controller.ts backs this page
|
|
16
|
+
* via `GET access/role-grants` + `GET permission-catalog`, BOTH gated
|
|
17
|
+
* `role:read` — "managing/reading grants IS role administration")
|
|
18
|
+
* - Audit → `identity:role:read` (audit.controller.ts `GET audit`)
|
|
19
|
+
* Dashboard / Account are left UNGATED — self/owned surfaces every authenticated
|
|
20
|
+
* caller may reach (no admin-only list route backs them).
|
|
21
|
+
*/
|
|
22
|
+
import {
|
|
23
|
+
Building2,
|
|
24
|
+
History,
|
|
25
|
+
KeyRound,
|
|
26
|
+
LayoutDashboard,
|
|
27
|
+
ShieldCheck,
|
|
28
|
+
UserCog,
|
|
29
|
+
Users,
|
|
30
|
+
} from 'lucide-react';
|
|
31
|
+
|
|
32
|
+
export const NAV_SECTIONS = [
|
|
33
|
+
{
|
|
34
|
+
label: 'Overview',
|
|
35
|
+
items: [{ icon: LayoutDashboard, label: 'Dashboard', path: '/dashboard', badge: null }],
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
label: 'Identity',
|
|
39
|
+
items: [
|
|
40
|
+
{
|
|
41
|
+
icon: Building2,
|
|
42
|
+
label: 'Companies',
|
|
43
|
+
path: '/companies',
|
|
44
|
+
badge: null,
|
|
45
|
+
matchPrefix: true,
|
|
46
|
+
gate: 'identity:company:read',
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
icon: Users,
|
|
50
|
+
label: 'Users',
|
|
51
|
+
path: '/users',
|
|
52
|
+
badge: null,
|
|
53
|
+
matchPrefix: true,
|
|
54
|
+
gate: 'identity:user:read',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
icon: ShieldCheck,
|
|
58
|
+
label: 'Roles',
|
|
59
|
+
path: '/roles',
|
|
60
|
+
badge: null,
|
|
61
|
+
matchPrefix: true,
|
|
62
|
+
gate: 'identity:role:read',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
icon: KeyRound,
|
|
66
|
+
label: 'Access',
|
|
67
|
+
path: '/access',
|
|
68
|
+
badge: null,
|
|
69
|
+
matchPrefix: true,
|
|
70
|
+
gate: 'identity:role:read',
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
icon: History,
|
|
74
|
+
label: 'Audit',
|
|
75
|
+
path: '/audit',
|
|
76
|
+
badge: null,
|
|
77
|
+
matchPrefix: true,
|
|
78
|
+
gate: 'identity:role:read',
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
label: 'Settings',
|
|
84
|
+
// `/account` redirects to `/account/profile`; `matchPrefix` keeps the nav item
|
|
85
|
+
// highlighted across ALL /account/* sub-tabs (profile / password / 2fa).
|
|
86
|
+
items: [
|
|
87
|
+
{ icon: UserCog, label: 'Account', path: '/account', badge: null, matchPrefix: true },
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
];
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ThemeProvider — light/dark/system via next-themes (OCA-style dual theme).
|
|
3
|
+
* Stamps `.dark` on <html>; the tokens in index.css do the rest. `attribute="class"`
|
|
4
|
+
* matches the `@custom-variant dark (&:is(.dark *))` in index.css.
|
|
5
|
+
*/
|
|
6
|
+
import { ThemeProvider as NextThemes } from 'next-themes';
|
|
7
|
+
|
|
8
|
+
export function ThemeProvider({ children }) {
|
|
9
|
+
return (
|
|
10
|
+
<NextThemes attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
|
11
|
+
{children}
|
|
12
|
+
</NextThemes>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ThemeToggle — an OCA-style Light/Dark/System segmented control (in the user menu
|
|
3
|
+
* or standalone). Uses next-themes; the active segment is highlighted.
|
|
4
|
+
*/
|
|
5
|
+
import { useEffect, useState } from 'react';
|
|
6
|
+
import { useTheme } from 'next-themes';
|
|
7
|
+
import { Monitor, Moon, Sun } from 'lucide-react';
|
|
8
|
+
import { cn } from '@/lib/utils';
|
|
9
|
+
|
|
10
|
+
const OPTIONS = [
|
|
11
|
+
{ value: 'light', icon: Sun, label: 'Light' },
|
|
12
|
+
{ value: 'dark', icon: Moon, label: 'Dark' },
|
|
13
|
+
{ value: 'system', icon: Monitor, label: 'System' },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
export default function ThemeToggle({ compact = false }) {
|
|
17
|
+
const { theme, setTheme } = useTheme();
|
|
18
|
+
const [mounted, setMounted] = useState(false);
|
|
19
|
+
useEffect(() => setMounted(true), []);
|
|
20
|
+
const active = mounted ? theme : 'system';
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div className="inline-flex items-center gap-0.5 rounded-lg border border-border bg-muted/50 p-0.5">
|
|
24
|
+
{OPTIONS.map(({ value, icon: Icon, label }) => (
|
|
25
|
+
<button
|
|
26
|
+
key={value}
|
|
27
|
+
type="button"
|
|
28
|
+
onClick={() => setTheme(value)}
|
|
29
|
+
aria-label={label}
|
|
30
|
+
title={label}
|
|
31
|
+
className={cn(
|
|
32
|
+
'flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium transition-colors',
|
|
33
|
+
active === value
|
|
34
|
+
? 'bg-background text-foreground shadow-sm'
|
|
35
|
+
: 'text-muted-foreground hover:text-foreground',
|
|
36
|
+
)}
|
|
37
|
+
>
|
|
38
|
+
<Icon className="size-3.5" />
|
|
39
|
+
{!compact && <span>{label}</span>}
|
|
40
|
+
</button>
|
|
41
|
+
))}
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"useDefineForClassFields": true,
|
|
5
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"moduleResolution": "bundler",
|
|
9
|
+
"allowImportingTsExtensions": true,
|
|
10
|
+
"resolveJsonModule": true,
|
|
11
|
+
"isolatedModules": true,
|
|
12
|
+
"noEmit": true,
|
|
13
|
+
"jsx": "react-jsx",
|
|
14
|
+
"allowJs": true,
|
|
15
|
+
"strict": false,
|
|
16
|
+
"baseUrl": ".",
|
|
17
|
+
"paths": {
|
|
18
|
+
"@/*": ["./src/*"]
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"include": ["src"]
|
|
22
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { defineConfig } from 'vite';
|
|
2
|
+
import react from '@vitejs/plugin-react';
|
|
3
|
+
import tailwindcss from '@tailwindcss/vite';
|
|
4
|
+
import { fileURLToPath, URL } from 'node:url';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* __APP_NAME__ frontend — Vite config (shadcn/ui + Tailwind v4).
|
|
8
|
+
*
|
|
9
|
+
* `/api` is proxied to the framework server (default :3000), stripping the `/api`
|
|
10
|
+
* prefix so the browser calls same-origin `/api/auth/login` while the server sees
|
|
11
|
+
* `/auth/login`. `@` aliases `src/` (the shadcn import convention).
|
|
12
|
+
*/
|
|
13
|
+
export default defineConfig({
|
|
14
|
+
plugins: [react(), tailwindcss()],
|
|
15
|
+
resolve: {
|
|
16
|
+
alias: {
|
|
17
|
+
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
server: {
|
|
21
|
+
port: 5173,
|
|
22
|
+
proxy: {
|
|
23
|
+
'/api': {
|
|
24
|
+
target: process.env.API_URL ?? 'http://localhost:3000',
|
|
25
|
+
changeOrigin: true,
|
|
26
|
+
rewrite: (path) => path.replace(/^\/api/, ''),
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
});
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
#
|
|
3
3
|
# Every value below has a safe default, so the app boots with NO .env file at all.
|
|
4
4
|
# Copy this file to `.env` and override only what you need. `.env` is gitignored.
|
|
5
|
-
#
|
|
6
5
|
# These are read by `loadAppConfig()` from the installed @astram/guard framework.
|
|
7
6
|
|
|
8
7
|
# HTTP port the app listens on (default: 3000).
|
|
@@ -13,21 +12,34 @@ NODE_ENV=development
|
|
|
13
12
|
|
|
14
13
|
# --- ADR-0018 connection-pool budget (all optional; framework defaults apply) ---
|
|
15
14
|
# Uncomment + tune only if your deployment needs to.
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
15
|
+
# POOL_WORKERS=1
|
|
16
|
+
# APP_DB_MAX_CONNECTIONS=50
|
|
17
|
+
# POOL_HEADROOM=5
|
|
18
|
+
# POOL_MAX_CORE=10
|
|
19
|
+
# POOL_MAX_APP_CRUD=10
|
|
20
|
+
# POOL_MAX_SCHEMA_SVC=5
|
|
22
21
|
|
|
23
22
|
# --- OPTIONAL: real authentication (only if you set ShieldModule.forRoot({ auth: true })) ---
|
|
24
23
|
# Auth needs a live MariaDB + Redis and a refresh-token HMAC key. These are
|
|
25
24
|
# REQUIRED once auth is enabled (the app fails closed without the HMAC key) — but
|
|
26
|
-
# irrelevant (unread) while auth is off.
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
25
|
+
# irrelevant (unread) while auth is off.
|
|
26
|
+
#
|
|
27
|
+
# Runtime app DB account (DML — the running server uses this):
|
|
28
|
+
# APP_DB_HOST=127.0.0.1
|
|
29
|
+
# APP_DB_PORT=3306
|
|
30
|
+
# APP_DB_USER=shield_core
|
|
31
|
+
# APP_DB_PASSWORD=change-me
|
|
32
|
+
# APP_DB_NAME=shield_identity
|
|
33
|
+
#
|
|
34
|
+
# Deploy-time DDL account (CREATE/ALTER/DROP — used only by `npm run migration:run`,
|
|
35
|
+
# NEVER by the running server; least-privilege). Host/port/db inherit APP_DB_* above.
|
|
36
|
+
# DDL_DB_USER=shield_migrate
|
|
37
|
+
# DDL_DB_PASSWORD=change-me
|
|
38
|
+
#
|
|
39
|
+
# Redis + refresh-token secret:
|
|
40
|
+
# REDIS_URL=redis://127.0.0.1:6379
|
|
41
|
+
# REFRESH_HMAC_KEY=change-me-to-a-long-random-secret # REQUIRED when auth is on
|
|
42
|
+
|
|
43
|
+
# --- Migrations (optional; @astram/identity's migrations run automatically) ---
|
|
44
|
+
# APP_MIGRATION_DIR=migrations # your app's own migrations, run after identity's
|
|
45
|
+
# APP_MIGRATION_TABLE=schema_migrations
|
|
@@ -11,17 +11,20 @@
|
|
|
11
11
|
"start": "node --env-file-if-exists=.env dist/main.js",
|
|
12
12
|
"dev": "node scripts/dev.mjs",
|
|
13
13
|
"test": "vitest run",
|
|
14
|
+
"migration:run": "node --env-file-if-exists=.env node_modules/@astram/migrate/dist/cli.js migrate",
|
|
15
|
+
"migration:status": "node --env-file-if-exists=.env node_modules/@astram/migrate/dist/cli.js migrate:status",
|
|
16
|
+
"migration:revert": "node --env-file-if-exists=.env node_modules/@astram/migrate/dist/cli.js migrate:revert",
|
|
14
17
|
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
|
|
15
18
|
"guide:sync": "node scripts/sync-astram-guide.mjs",
|
|
16
19
|
"postinstall": "node scripts/sync-astram-guide.mjs"
|
|
17
20
|
},
|
|
18
21
|
"dependencies": {
|
|
19
22
|
"@astram/core": "^1.0.0",
|
|
20
|
-
"@astram/engine": "^0.1.
|
|
21
|
-
"@astram/guard": "^0.
|
|
23
|
+
"@astram/engine": "^0.1.2",
|
|
24
|
+
"@astram/guard": "^0.4.1",
|
|
22
25
|
"@astram/guide": "^0.1.0",
|
|
23
|
-
"@astram/identity": "^0.1.
|
|
24
|
-
"@astram/migrate": "^0.1.
|
|
26
|
+
"@astram/identity": "^0.1.2",
|
|
27
|
+
"@astram/migrate": "^0.1.1",
|
|
25
28
|
"@astram/sdk": "^0.2.0",
|
|
26
29
|
"@nestjs/common": "^11.0.0",
|
|
27
30
|
"@nestjs/core": "^11.0.0",
|