create-astram-app 0.1.3 → 0.3.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 +191 -0
- package/template/frontend/src/auth/AuthContext.jsx +343 -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/cross-tenant/GrantForm.jsx +337 -0
- package/template/frontend/src/pages/cross-tenant/GrantsPage.jsx +237 -0
- package/template/frontend/src/pages/cross-tenant/InboxPage.jsx +179 -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 +103 -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
- package/template/server/src/app.module.ts +28 -2
- package/template/server/src/health/demo.controller.ts +23 -12
|
@@ -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,103 @@
|
|
|
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
|
+
* - Cross-tenant → `identity:role:read` (cross-tenant-grant.controller.ts `GET
|
|
20
|
+
* cross-tenant-grants` / `GET .../inbox` / `GET .../offerable-types`,
|
|
21
|
+
* all gated `role:read` — reading/managing cross-tenant grants IS role
|
|
22
|
+
* administration; the OFFER write is separately `cross_tenant_grant:share`)
|
|
23
|
+
* Dashboard / Account are left UNGATED — self/owned surfaces every authenticated
|
|
24
|
+
* caller may reach (no admin-only list route backs them).
|
|
25
|
+
*/
|
|
26
|
+
import {
|
|
27
|
+
Building2,
|
|
28
|
+
Handshake,
|
|
29
|
+
History,
|
|
30
|
+
KeyRound,
|
|
31
|
+
LayoutDashboard,
|
|
32
|
+
ShieldCheck,
|
|
33
|
+
UserCog,
|
|
34
|
+
Users,
|
|
35
|
+
} from 'lucide-react';
|
|
36
|
+
|
|
37
|
+
export const NAV_SECTIONS = [
|
|
38
|
+
{
|
|
39
|
+
label: 'Overview',
|
|
40
|
+
items: [{ icon: LayoutDashboard, label: 'Dashboard', path: '/dashboard', badge: null }],
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
label: 'Identity',
|
|
44
|
+
items: [
|
|
45
|
+
{
|
|
46
|
+
icon: Building2,
|
|
47
|
+
label: 'Companies',
|
|
48
|
+
path: '/companies',
|
|
49
|
+
badge: null,
|
|
50
|
+
matchPrefix: true,
|
|
51
|
+
gate: 'identity:company:read',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
icon: Users,
|
|
55
|
+
label: 'Users',
|
|
56
|
+
path: '/users',
|
|
57
|
+
badge: null,
|
|
58
|
+
matchPrefix: true,
|
|
59
|
+
gate: 'identity:user:read',
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
icon: ShieldCheck,
|
|
63
|
+
label: 'Roles',
|
|
64
|
+
path: '/roles',
|
|
65
|
+
badge: null,
|
|
66
|
+
matchPrefix: true,
|
|
67
|
+
gate: 'identity:role:read',
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
icon: KeyRound,
|
|
71
|
+
label: 'Access',
|
|
72
|
+
path: '/access',
|
|
73
|
+
badge: null,
|
|
74
|
+
matchPrefix: true,
|
|
75
|
+
gate: 'identity:role:read',
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
icon: Handshake,
|
|
79
|
+
label: 'Cross-tenant',
|
|
80
|
+
path: '/cross-tenant',
|
|
81
|
+
badge: null,
|
|
82
|
+
matchPrefix: true,
|
|
83
|
+
gate: 'identity:role:read',
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
icon: History,
|
|
87
|
+
label: 'Audit',
|
|
88
|
+
path: '/audit',
|
|
89
|
+
badge: null,
|
|
90
|
+
matchPrefix: true,
|
|
91
|
+
gate: 'identity:role:read',
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
label: 'Settings',
|
|
97
|
+
// `/account` redirects to `/account/profile`; `matchPrefix` keeps the nav item
|
|
98
|
+
// highlighted across ALL /account/* sub-tabs (profile / password / 2fa).
|
|
99
|
+
items: [
|
|
100
|
+
{ icon: UserCog, label: 'Account', path: '/account', badge: null, matchPrefix: true },
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
];
|
|
@@ -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.3",
|
|
24
|
+
"@astram/guard": "^0.5.0",
|
|
22
25
|
"@astram/guide": "^0.1.0",
|
|
23
|
-
"@astram/identity": "^0.
|
|
24
|
-
"@astram/migrate": "^0.
|
|
26
|
+
"@astram/identity": "^0.2.0",
|
|
27
|
+
"@astram/migrate": "^0.2.0",
|
|
25
28
|
"@astram/sdk": "^0.2.0",
|
|
26
29
|
"@nestjs/common": "^11.0.0",
|
|
27
30
|
"@nestjs/core": "^11.0.0",
|
|
@@ -35,10 +35,36 @@ import { DemoController } from './health/demo.controller';
|
|
|
35
35
|
* You then get framework-owned `POST /auth/{login,mfa,refresh,logout}` for free —
|
|
36
36
|
* no controllers to write. (Leaving `auth` off keeps the zero-config enforcement
|
|
37
37
|
* boot; turning it on requires the config above, by design — fail-closed.)
|
|
38
|
+
*
|
|
39
|
+
* ── OPTIONAL: the identity ADMIN CONSOLE (companies/users/roles CRUD) ──────────
|
|
40
|
+
* The framework also ships the identity management surface — company/user/role CRUD
|
|
41
|
+
* (memberships, access, audit in later versions) — as controllers you mount by
|
|
42
|
+
* importing ONE module. `@astram/guard` never imports `@astram/identity` (the
|
|
43
|
+
* dependency graph is identity → guard), so the consumer wires it (ADR-0026):
|
|
44
|
+
* 1. Turn auth on (above), pass the identity permission keys (so the frontend admin
|
|
45
|
+
* nav lights up), AND pass `recordAuthorization` (so the decision guard actually
|
|
46
|
+
* GRANTS — without it, decide() denies every classified route):
|
|
47
|
+
* import { identityAuthActionKeys, identityControlPlaneKeys, IdentityFeatureModule,
|
|
48
|
+
* createIdentityAuthzPool, buildIdentityRecordAuthorization } from '@astram/identity';
|
|
49
|
+
* const authzPool = createIdentityAuthzPool();
|
|
50
|
+
* imports: [
|
|
51
|
+
* ShieldModule.forRoot({ auth: true,
|
|
52
|
+
* identityActionKeys: identityAuthActionKeys, identityControlPlaneKeys,
|
|
53
|
+
* recordAuthorization: buildIdentityRecordAuthorization(authzPool) }),
|
|
54
|
+
* IdentityFeatureModule.forRoot(), // mounts /identity/{companies,users,roles}
|
|
55
|
+
* ],
|
|
56
|
+
* `IdentityFeatureModule.forRoot()` self-wires the engine from your `APP_DB_*` env
|
|
57
|
+
* (no separate engine setup). Every route is `@Classified` — enforced by the same
|
|
58
|
+
* deny-by-default guard; `recordAuthorization` makes that guard render REAL decisions
|
|
59
|
+
* from your roles' grants. Omit the module and login still works; the admin pages 404.
|
|
38
60
|
*/
|
|
39
61
|
@Module({
|
|
40
|
-
// Enforcement-only by default (boots with no config). For real login
|
|
41
|
-
//
|
|
62
|
+
// Enforcement-only by default (boots with no config). For real login + the identity
|
|
63
|
+
// admin console, use (see the OPTIONAL blocks above for the full form):
|
|
64
|
+
// imports: [
|
|
65
|
+
// ShieldModule.forRoot({ auth: true, identityActionKeys, identityControlPlaneKeys }),
|
|
66
|
+
// IdentityFeatureModule.forRoot(),
|
|
67
|
+
// ],
|
|
42
68
|
imports: [ShieldModule.forRoot()],
|
|
43
69
|
controllers: [HealthController, DemoController],
|
|
44
70
|
})
|
|
@@ -2,15 +2,23 @@ import { Controller, Get } from '@nestjs/common';
|
|
|
2
2
|
import { Public } from '@astram/guard';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Demonstration
|
|
5
|
+
* Demonstration route proving the enforcement seam (ADR-0002).
|
|
6
6
|
*
|
|
7
|
-
* - `GET /demo/public`
|
|
8
|
-
*
|
|
7
|
+
* - `GET /demo/public` → `@Public()` → REACHABLE (200): an explicit opt-out of the
|
|
8
|
+
* deny-by-default global guard.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* Deny-by-default has TWO layers, and they differ in WHEN they bite:
|
|
11
|
+
* 1. **Boot-time (fail-closed, strongest):** the identity boot check (`verifyGatable`)
|
|
12
|
+
* scans every route and ABORTS BOOT if any non-`@Public` route lacks `@Classified`.
|
|
13
|
+
* A forgotten declaration doesn't just get denied at request time — the app won't
|
|
14
|
+
* start. That is why the illustrative "unclassified route" below is a COMMENT, not a
|
|
15
|
+
* live handler: a live one would prevent this app from booting. Uncomment it only to
|
|
16
|
+
* SEE the boot abort, then re-comment (or classify it).
|
|
17
|
+
* 2. **Request-time (per-request):** even absent the boot check, the global guard denies
|
|
18
|
+
* an unclassified route with 403.
|
|
19
|
+
*
|
|
20
|
+
* This controller is illustrative — delete it once you've seen enforcement work; a real
|
|
21
|
+
* feature classifies every route with `@Public()` or `@Classified(...)`.
|
|
14
22
|
*/
|
|
15
23
|
@Controller('demo')
|
|
16
24
|
export class DemoController {
|
|
@@ -20,9 +28,12 @@ export class DemoController {
|
|
|
20
28
|
return { message: 'public route reachable — explicit @Public() opt-out' };
|
|
21
29
|
}
|
|
22
30
|
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
31
|
+
// Intentionally NO @Public() / @Classified(). Kept as a COMMENT because the boot-time
|
|
32
|
+
// `verifyGatable` (fail-closed) aborts boot on any unclassified route — a live handler
|
|
33
|
+
// here would stop the app from starting. Uncomment to witness the boot abort, then
|
|
34
|
+
// re-comment or classify it.
|
|
35
|
+
// @Get('unclassified')
|
|
36
|
+
// unclassifiedRoute(): { message: string } {
|
|
37
|
+
// return { message: 'this body must never be reached — the guard denies first' };
|
|
38
|
+
// }
|
|
28
39
|
}
|