Add paginated users/roles, user status, frontend permissions, profile pictures, identifier file storage

- Users page: paginated, searchable, sortable with inline roles (no N+1)
- Roles page: paginated, searchable, sortable + /roles/all for dropdowns
- User is_active field with migration, PATCH toggle, auth check (disabled=401)
- Frontend permission checks: auth store loads permissions, sidebar/buttons conditional
- Profile pictures via file storage for users and members, avatar component
- Identifier images use file storage API instead of base64
- Fix TypeScript errors across admin UI
- 64 API tests passing (10 new)
This commit is contained in:
Ryan Moon
2026-03-29 08:16:34 -05:00
parent 92371ff228
commit b9f78639e2
48 changed files with 1689 additions and 643 deletions

View File

@@ -12,16 +12,49 @@ interface AuthState {
token: string | null
user: User | null
companyId: string | null
permissions: Set<string>
permissionsLoaded: boolean
setAuth: (token: string, user: User) => void
setPermissions: (slugs: string[]) => void
hasPermission: (slug: string) => boolean
logout: () => void
}
/**
* Permission inheritance: admin implies edit implies view for the same domain.
* Must match the backend logic in plugins/auth.ts.
*/
const ACTION_HIERARCHY: Record<string, string[]> = {
admin: ['admin', 'edit', 'view'],
edit: ['edit', 'view'],
view: ['view'],
upload: ['upload'],
delete: ['delete'],
send: ['send'],
export: ['export'],
}
function expandPermissions(slugs: string[]): Set<string> {
const expanded = new Set<string>()
for (const slug of slugs) {
expanded.add(slug)
const [domain, action] = slug.split('.')
const implied = ACTION_HIERARCHY[action]
if (implied && domain) {
for (const a of implied) {
expanded.add(`${domain}.${a}`)
}
}
}
return expanded
}
function decodeJwtPayload(token: string): { id: string; companyId: string; role: string } {
const payload = token.split('.')[1]
return JSON.parse(atob(payload))
}
function loadSession(): { token: string; user: User; companyId: string } | null {
function loadSession(): { token: string; user: User; companyId: string; permissions?: string[] } | null {
try {
const raw = sessionStorage.getItem('forte-auth')
if (!raw) return null
@@ -31,20 +64,23 @@ function loadSession(): { token: string; user: User; companyId: string } | null
}
}
function saveSession(token: string, user: User, companyId: string) {
sessionStorage.setItem('forte-auth', JSON.stringify({ token, user, companyId }))
function saveSession(token: string, user: User, companyId: string, permissions?: string[]) {
sessionStorage.setItem('forte-auth', JSON.stringify({ token, user, companyId, permissions }))
}
function clearSession() {
sessionStorage.removeItem('forte-auth')
}
export const useAuthStore = create<AuthState>((set) => {
export const useAuthStore = create<AuthState>((set, get) => {
const initial = typeof window !== 'undefined' ? loadSession() : null
const initialPerms = initial?.permissions ? expandPermissions(initial.permissions) : new Set<string>()
return {
token: initial?.token ?? null,
user: initial?.user ?? null,
companyId: initial?.companyId ?? null,
permissions: initialPerms,
permissionsLoaded: initialPerms.size > 0,
setAuth: (token, user) => {
const payload = decodeJwtPayload(token)
@@ -52,8 +88,22 @@ export const useAuthStore = create<AuthState>((set) => {
set({ token, user, companyId: payload.companyId })
},
setPermissions: (slugs: string[]) => {
const expanded = expandPermissions(slugs)
// Update session storage to include permissions
const { token, user, companyId } = get()
if (token && user && companyId) {
saveSession(token, user, companyId, slugs)
}
set({ permissions: expanded, permissionsLoaded: true })
},
hasPermission: (slug: string) => {
return get().permissions.has(slug)
},
logout: () => {
clearSession()
set({ token: null, user: null, companyId: null })
set({ token: null, user: null, companyId: null, permissions: new Set(), permissionsLoaded: false })
},
}})