@postxl/generators 1.11.7 → 1.12.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.
Files changed (40) hide show
  1. package/dist/backend-view/model-view-service.generator.js +4 -1
  2. package/dist/backend-view/model-view-service.generator.js.map +1 -1
  3. package/dist/backend-view/template/query.utils.test.ts +34 -0
  4. package/dist/backend-view/template/query.utils.ts +12 -0
  5. package/dist/frontend-admin/admin.generator.d.ts +9 -0
  6. package/dist/frontend-admin/admin.generator.js +19 -0
  7. package/dist/frontend-admin/admin.generator.js.map +1 -1
  8. package/dist/frontend-admin/generators/admin-sidebar.generator.js +1 -1
  9. package/dist/frontend-admin/generators/audit-log-sidebar.generator.js +107 -113
  10. package/dist/frontend-admin/generators/audit-log-sidebar.generator.js.map +1 -1
  11. package/dist/frontend-admin/generators/comment-sidebar.generator.d.ts +9 -0
  12. package/dist/frontend-admin/generators/comment-sidebar.generator.js +246 -0
  13. package/dist/frontend-admin/generators/comment-sidebar.generator.js.map +1 -0
  14. package/dist/frontend-admin/generators/detail-sidebar.generator.d.ts +9 -0
  15. package/dist/frontend-admin/generators/detail-sidebar.generator.js +148 -0
  16. package/dist/frontend-admin/generators/detail-sidebar.generator.js.map +1 -0
  17. package/dist/frontend-admin/generators/model-admin-page.generator.js +40 -6
  18. package/dist/frontend-admin/generators/model-admin-page.generator.js.map +1 -1
  19. package/dist/frontend-core/frontend.generator.js +2 -1
  20. package/dist/frontend-core/frontend.generator.js.map +1 -1
  21. package/dist/frontend-core/template/docs/LOGIN_PROCESS.d2 +372 -0
  22. package/dist/frontend-core/template/docs/LOGIN_PROCESS.md +214 -0
  23. package/dist/frontend-core/template/docs/LOGIN_PROCESS.svg +914 -0
  24. package/dist/frontend-core/template/src/components/admin/table-filter.tsx +1 -1
  25. package/dist/frontend-core/template/src/components/admin/table-view-panel.tsx +159 -0
  26. package/dist/frontend-core/template/src/context-providers/auth-context-provider.tsx +54 -43
  27. package/dist/frontend-core/template/src/hooks/use-table-view-config.tsx +249 -0
  28. package/dist/frontend-core/template/src/pages/dashboard/dashboard.page.tsx +5 -5
  29. package/dist/frontend-core/template/src/pages/error/auth-error.page.tsx +37 -0
  30. package/dist/frontend-core/template/src/pages/error/default-error.page.tsx +24 -40
  31. package/dist/frontend-core/template/src/pages/error/not-found-error.page.tsx +8 -10
  32. package/dist/frontend-core/template/src/pages/login/login.page.tsx +39 -13
  33. package/dist/frontend-core/template/src/pages/unauthorized/unauthorized.page.tsx +2 -2
  34. package/dist/frontend-core/template/src/routes/_auth-routes.tsx +21 -8
  35. package/dist/frontend-core/template/src/routes/auth-error.tsx +19 -0
  36. package/dist/frontend-core/template/vite.config.ts +5 -0
  37. package/dist/frontend-core/types/hook.d.ts +1 -1
  38. package/dist/frontend-tables/generators/model-table.generator.js +30 -5
  39. package/dist/frontend-tables/generators/model-table.generator.js.map +1 -1
  40. package/package.json +5 -5
@@ -566,7 +566,7 @@ export const TableFilter = <TField extends FilterFieldName, TFilters extends Fil
566
566
  options.length === 0 ? (
567
567
  <div className="py-6 text-center text-sm">No options available.</div>
568
568
  ) : (
569
- <div className="px-1 py-1 max-h-[250px] overflow-auto">
569
+ <div className="px-1 py-1 max-h-[200px] overflow-auto">
570
570
  {options.map((option) => (
571
571
  <SlicerHierarchyItem
572
572
  key={option.value}
@@ -0,0 +1,159 @@
1
+ import type { Table } from '@tanstack/react-table'
2
+
3
+ import { useCallback, useEffect, useRef } from 'react'
4
+ import { toast } from 'sonner'
5
+
6
+ import { useTableViewConfig, type TableViewState } from '@hooks/use-table-view-config'
7
+ import { DataGridViewMenu } from '@postxl/ui-components'
8
+
9
+ export type TableViewPanelProps<TData> = {
10
+ /** The model/table name to scope views to (e.g. 'user', 'publication'). */
11
+ model: string
12
+ /** The TanStack Table instance (from useDataGrid). */
13
+ table: Table<TData>
14
+ /** The current user's ID. */
15
+ currentUserId?: string
16
+ /** Whether the current user has admin privileges. */
17
+ isAdmin?: boolean
18
+ /** Current filter state — included when saving a view. */
19
+ filters?: Record<string, unknown>
20
+ /** Current sort state — included when saving a view. */
21
+ sort?: { field: string; direction: 'asc' | 'desc' }[]
22
+ /** Called when a view is applied and its filters should be restored. */
23
+ onFiltersChange?: (filters: Record<string, unknown>) => void
24
+ /** Called when a view is applied and its sort should be restored. */
25
+ onSortChange?: (sort: { field: string; direction: 'asc' | 'desc' }[]) => void
26
+ }
27
+
28
+ /**
29
+ * Connects the table view config hook to the DataGridViewMenu component.
30
+ *
31
+ * Handles:
32
+ * - Collecting current table state (filters, sort, column visibility/order/pinning)
33
+ * - Saving/updating views with automatic JSON serialization
34
+ * - Applying loaded views back to the table and page filter/sort state
35
+ * - Auto-restoring the previously active view on mount
36
+ * - Rename and delete operations
37
+ */
38
+ export function TableViewPanel<TData>({
39
+ model,
40
+ table,
41
+ currentUserId,
42
+ isAdmin,
43
+ filters,
44
+ sort,
45
+ onFiltersChange,
46
+ onSortChange,
47
+ }: TableViewPanelProps<TData>) {
48
+ const viewConfig = useTableViewConfig({ model, currentUserId })
49
+ const hasAutoAppliedRef = useRef(false)
50
+
51
+ const applyViewState = useCallback(
52
+ (viewId: string) => {
53
+ const view = viewConfig.views.find((v) => v.id === viewId)
54
+ if (!view) return
55
+ const state = viewConfig.parseViewState(view)
56
+ if (onFiltersChange) {
57
+ onFiltersChange(state.filters ?? {})
58
+ }
59
+ if (onSortChange) {
60
+ onSortChange(state.sorting ?? [])
61
+ }
62
+ if (state.columnVisibility) {
63
+ table.setColumnVisibility(state.columnVisibility)
64
+ }
65
+ if (state.columnOrder) {
66
+ table.setColumnOrder(state.columnOrder)
67
+ }
68
+ if (state.columnPinning) {
69
+ table.setColumnPinning(state.columnPinning)
70
+ }
71
+ if (state.columnSizing) {
72
+ table.setColumnSizing(state.columnSizing)
73
+ }
74
+ },
75
+ [viewConfig, onFiltersChange, onSortChange, table],
76
+ )
77
+
78
+ // Auto-apply persisted view once views have loaded
79
+ useEffect(() => {
80
+ if (!viewConfig.isLoaded || hasAutoAppliedRef.current) return
81
+ hasAutoAppliedRef.current = true
82
+
83
+ if (viewConfig.activeViewId && viewConfig.views.find((v) => v.id === viewConfig.activeViewId)) {
84
+ applyViewState(viewConfig.activeViewId)
85
+ }
86
+ }, [viewConfig.isLoaded, viewConfig.activeViewId, viewConfig.views, applyViewState])
87
+
88
+ const collectTableState = useCallback(
89
+ (): TableViewState => ({
90
+ filters: filters,
91
+ sorting: sort,
92
+ columnVisibility: table.getState().columnVisibility,
93
+ columnOrder: table.getState().columnOrder,
94
+ columnPinning: table.getState().columnPinning as { left?: string[]; right?: string[] },
95
+ columnSizing: table.getState().columnSizing,
96
+ }),
97
+ [filters, sort, table],
98
+ )
99
+
100
+ const handleSaveView = useCallback(
101
+ ({ name, isGlobal }: { name: string; isGlobal: boolean }) => {
102
+ void viewConfig.saveView({ name, isGlobal, state: collectTableState() }).then((view) => {
103
+ toast.success(`View "${view.name}" saved`)
104
+ })
105
+ },
106
+ [viewConfig, collectTableState],
107
+ )
108
+
109
+ const handleUpdateView = useCallback(
110
+ (viewId: string) => {
111
+ void viewConfig.updateView(viewId, collectTableState()).then(() => {
112
+ toast.success('View updated')
113
+ })
114
+ },
115
+ [viewConfig, collectTableState],
116
+ )
117
+
118
+ const handleApplyView = useCallback(
119
+ (viewId: string) => {
120
+ applyViewState(viewId)
121
+ viewConfig.setActiveViewId(viewId)
122
+ },
123
+ [applyViewState, viewConfig],
124
+ )
125
+
126
+ const handleRenameView = useCallback(
127
+ (viewId: string, name: string) => {
128
+ void viewConfig.renameView(viewId, name).then(() => {
129
+ toast.success(`View renamed to "${name}"`)
130
+ })
131
+ },
132
+ [viewConfig],
133
+ )
134
+
135
+ const handleDeleteView = useCallback(
136
+ (viewId: string) => {
137
+ void viewConfig.deleteView(viewId).then(() => {
138
+ toast.success('View deleted')
139
+ })
140
+ },
141
+ [viewConfig],
142
+ )
143
+
144
+ return (
145
+ <DataGridViewMenu
146
+ table={table}
147
+ savedViews={viewConfig.savedViews}
148
+ activeViewId={viewConfig.activeViewId}
149
+ currentUserId={currentUserId}
150
+ isAdmin={isAdmin}
151
+ onApplyView={handleApplyView}
152
+ onSaveView={handleSaveView}
153
+ onUpdateView={handleUpdateView}
154
+ onRenameView={handleRenameView}
155
+ onDeleteView={handleDeleteView}
156
+ onClearView={viewConfig.clearActiveView}
157
+ />
158
+ )
159
+ }
@@ -1,6 +1,6 @@
1
- import type { UserRoles, ViewerResult } from '@authentication/authentication.types'
1
+ import type { Viewer } from '@authentication/auth.guard'
2
+ import type { ViewerResult } from '@authentication/authentication.types'
2
3
  import { useQuery } from '@tanstack/react-query'
3
- import type { User } from '@types'
4
4
 
5
5
  import Keycloak from 'keycloak-js'
6
6
  import * as React from 'react'
@@ -11,17 +11,18 @@ import { createUseContext } from '@lib/react'
11
11
  import { useTRPC } from '@lib/trpc'
12
12
  import { Result } from '@postxl/utils'
13
13
 
14
- type AppUser = {
15
- userDetails: User | null
16
- userRoles: UserRoles
17
- }
14
+ // Frontend version of Viewer without userInfo (which is only available server-side)
15
+ type FrontendViewer = Omit<Viewer, 'userInfo'>
16
+
17
+ export const authErrorTypes = ['errorInit', 'errorLoadUser', 'errorTokenRefresh'] as const
18
+ export type AuthErrorType = (typeof authErrorTypes)[number]
19
+ export type AuthState = 'inProgress' | 'loggedIn' | 'loggedOut'
18
20
 
19
21
  export type AuthContext = {
20
- isAuthenticated: boolean
21
- isInitComplete: boolean
22
+ loginState: AuthState | AuthErrorType
22
23
  login: () => Promise<void>
23
- logout: () => Promise<void>
24
- user: AppUser | null
24
+ logout: (options?: { redirectUri?: string }) => Promise<void>
25
+ viewerData: FrontendViewer | null
25
26
  }
26
27
 
27
28
  const AuthContext = createContext<AuthContext | null>(null)
@@ -42,7 +43,7 @@ export function getAuthHeaders(options?: RequestInit) {
42
43
  const headers = new Headers(options?.headers)
43
44
 
44
45
  if (token) {
45
- headers.set('Authorization', `Bearer${token}`)
46
+ headers.set('Authorization', `Bearer ${token}`)
46
47
  }
47
48
  return headers
48
49
  }
@@ -72,9 +73,8 @@ keycloak.onAuthError = () => {
72
73
  }
73
74
 
74
75
  export function AuthProvider({ children }: Readonly<{ children: React.ReactNode }>) {
75
- const [user, setUser] = React.useState<AppUser | null>(null)
76
- const [isAuthenticated, setIsAuthenticated] = useState(!APP_CONFIG.AUTH)
77
- const [isInitComplete, setIsInitComplete] = useState(!APP_CONFIG.AUTH)
76
+ const [viewerData, setViewerData] = React.useState<FrontendViewer | null>(null)
77
+ const [loginState, setLoginState] = useState<AuthState | AuthErrorType>('inProgress')
78
78
 
79
79
  const trpc = useTRPC()
80
80
 
@@ -86,13 +86,13 @@ export function AuthProvider({ children }: Readonly<{ children: React.ReactNode
86
86
  trpc.viewer.viewer.queryOptions(
87
87
  { pageUrl: location.href },
88
88
  // load user data when keycloak init complete and user is authenticated
89
- { enabled: isInitComplete && isAuthenticated },
89
+ { enabled: loginState === 'loggedIn' },
90
90
  ),
91
91
  )
92
92
 
93
- const logout = useCallback(async () => {
93
+ const logout = useCallback(async (options?: { redirectUri?: string }) => {
94
94
  setAuthToken()
95
- await keycloak.logout()
95
+ await keycloak.logout(options)
96
96
  }, [])
97
97
 
98
98
  const login = useCallback(async () => {
@@ -120,59 +120,66 @@ export function AuthProvider({ children }: Readonly<{ children: React.ReactNode
120
120
  checkLoginIframe: APP_CONFIG.checkLoginIframe,
121
121
  })
122
122
 
123
- if (authenticated) {
123
+ console.log(
124
+ 'Keycloak initialized COMPLETE, user authenticated:',
125
+ authenticated,
126
+ 'token:',
127
+ keycloak.token ? 'present' : 'missing',
128
+ )
129
+
130
+ if (authenticated && keycloak.token) {
124
131
  setAuthToken(keycloak.token)
125
- setIsAuthenticated(true)
132
+ setLoginState('loggedIn')
133
+ } else {
134
+ // if keycloak initialized and the user is not authenticated,
135
+ // the user will be redirected to the keycloak login page (see "/pages/Login")
136
+ setLoginState('loggedOut')
126
137
  }
127
- // if keycloak initialized and the user is not authenticated,
128
- // the user will be redirected to the keycloak login page (see "/pages/Login")
129
- console.log('Keycloak initialized COMPLETE, user authenticated:', authenticated)
130
- setIsInitComplete(true)
131
138
  }
132
139
  } catch (error) {
133
140
  console.error('Failed to initialize adapter:', error)
134
- void logout() // NOSONAR
141
+ setLoginState('errorInit')
135
142
  }
136
143
  }
137
144
 
138
145
  if (APP_CONFIG.AUTH) {
139
146
  void initKeycloak()
147
+ } else {
148
+ setLoginState('loggedIn')
140
149
  }
141
- }, [logout])
150
+ }, [])
142
151
 
143
152
  /**
144
153
  * #2 - user data loading
145
154
  * when user authenticated by keycloak, the user data is loaded from the backend
146
155
  */
147
156
  useEffect(() => {
148
- if (isInitComplete && isUserDataError) {
149
- void logout() // NOSONAR
157
+ if (loginState === 'loggedIn' && isUserDataError) {
158
+ setLoginState('errorLoadUser')
150
159
  return
151
160
  }
152
161
 
153
162
  if (userData && isUserSuccessfullyLoaded) {
154
- const userDataResult: ViewerResult = Result.fromObject(userData as unknown as any) as unknown as ViewerResult
163
+ const userDataResult: ViewerResult = Result.fromObject(userData as any) as ViewerResult
155
164
  if (userDataResult.isOk()) {
156
165
  const value = userDataResult.unwrap()
157
- console.log('User data loaded successfully:', value)
158
- setUser({
159
- userRoles: value.userRoles,
160
- userDetails: value.user,
161
- })
166
+ console.log('User data loaded successfully')
167
+ setViewerData(value)
162
168
  } else if (userDataResult.isErr()) {
163
- console.log('User data loaded with an error => logout()', { error: userDataResult.unwrapErr() })
164
- // when we get an error result on loading the user, we log out the user
165
- void logout() // NOSONAR
169
+ console.log('User data loaded with an error => logout and redirect to auth-error', {
170
+ error: userDataResult.unwrapErr(),
171
+ })
172
+ setLoginState('errorLoadUser')
166
173
  }
167
174
  }
168
- }, [userData, isUserSuccessfullyLoaded, isUserDataError, isInitComplete, logout])
175
+ }, [isUserDataError, isUserSuccessfullyLoaded, loginState, userData])
169
176
 
170
177
  /**
171
178
  * #3 - token refreshing
172
179
  * when the user is authenticated, the token is refreshed every 5 seconds
173
180
  */
174
181
  useEffect(() => {
175
- if (!isInitComplete || !isAuthenticated || !APP_CONFIG.AUTH) {
182
+ if (loginState !== 'loggedIn' || !APP_CONFIG.AUTH) {
176
183
  return
177
184
  }
178
185
 
@@ -188,17 +195,21 @@ export function AuthProvider({ children }: Readonly<{ children: React.ReactNode
188
195
  }
189
196
  } catch (error) {
190
197
  console.error('Failed to refresh token', error)
191
- // if the token could not be refreshed, log out the user
192
- void logout() // NOSONAR
198
+ setLoginState('errorTokenRefresh')
193
199
  }
194
200
  }, 5000)
195
201
 
196
202
  return () => clearInterval(refreshIntervalId)
197
- }, [isAuthenticated, isInitComplete, logout])
203
+ }, [loginState])
198
204
 
199
205
  const memoizedValues = useMemo(
200
- () => ({ isAuthenticated, login, logout, user, isInitComplete }),
201
- [isAuthenticated, login, logout, user, isInitComplete],
206
+ () => ({
207
+ loginState,
208
+ login,
209
+ logout,
210
+ viewerData,
211
+ }),
212
+ [loginState, login, logout, viewerData],
202
213
  )
203
214
 
204
215
  return <AuthContext.Provider value={memoizedValues}>{children}</AuthContext.Provider>
@@ -0,0 +1,249 @@
1
+ import type { TableViewId, TableViewViewModel } from '@types'
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
4
+ import { z } from 'zod'
5
+
6
+ import { useTableViews } from './use-table-views'
7
+
8
+ /** Creates a Zod decoder that parses a JSON string and validates the result against the given schema. */
9
+ const jsonDecoder = <T,>(schema: z.ZodType<T>) =>
10
+ z
11
+ .string()
12
+ .transform((s) => JSON.parse(s) as unknown)
13
+ .pipe(schema)
14
+
15
+ const filtersDecoder = jsonDecoder(z.record(z.string(), z.unknown()))
16
+ const sortingDecoder = jsonDecoder(z.array(z.object({ field: z.string(), direction: z.enum(['asc', 'desc']) })))
17
+ const columnVisibilityDecoder = jsonDecoder(z.record(z.string(), z.boolean()))
18
+ const columnOrderDecoder = jsonDecoder(z.array(z.string()))
19
+ const columnPinningDecoder = jsonDecoder(
20
+ z.object({ left: z.array(z.string()).optional(), right: z.array(z.string()).optional() }),
21
+ )
22
+ const columnSizingDecoder = jsonDecoder(z.record(z.string(), z.number()))
23
+
24
+ /**
25
+ * Parsed table state stored in a TableView.
26
+ * All JSON string fields are deserialized into their typed representations.
27
+ */
28
+ export type TableViewState = {
29
+ filters?: z.infer<typeof filtersDecoder>
30
+ sorting?: z.infer<typeof sortingDecoder>
31
+ columnVisibility?: z.infer<typeof columnVisibilityDecoder>
32
+ columnOrder?: z.infer<typeof columnOrderDecoder>
33
+ columnPinning?: z.infer<typeof columnPinningDecoder>
34
+ columnSizing?: z.infer<typeof columnSizingDecoder>
35
+ }
36
+
37
+ export type UseTableViewConfigOptions = {
38
+ /** The model/table name to scope views to (e.g. 'user', 'publication'). */
39
+ model: string
40
+ /** The current user's ID — used to filter views (own + global). */
41
+ currentUserId?: string
42
+ }
43
+
44
+ export type UseTableViewConfigResult = {
45
+ /** Views for this model visible to the current user (own + global). */
46
+ views: TableViewViewModel[]
47
+ /** Views mapped to the SavedView shape expected by DataGridViewMenu. */
48
+ savedViews: { id: string; name: string; isGlobal: boolean; userId: string }[]
49
+ /** Whether the initial data has been loaded. */
50
+ isLoaded: boolean
51
+ /** The currently active/applied view ID (local state, persisted to localStorage). */
52
+ activeViewId: string | null
53
+ /** Set the active view ID (also persists to localStorage). */
54
+ setActiveViewId: (id: string | null) => void
55
+ /** Clear the active view. */
56
+ clearActiveView: () => void
57
+ /** Parse a view's JSON string fields into typed TableViewState. */
58
+ parseViewState: (view: TableViewViewModel) => TableViewState
59
+ /** Get the parsed state of the currently active view, or null. */
60
+ getActiveViewState: () => TableViewState | null
61
+ /** Save a new view with the given state. Sets it as active on success. */
62
+ saveView: (args: { name: string; isGlobal?: boolean; state: TableViewState }) => Promise<TableViewViewModel>
63
+ /** Update an existing view's state. */
64
+ updateView: (viewId: string, state: TableViewState) => Promise<TableViewViewModel>
65
+ /** Rename an existing view. */
66
+ renameView: (viewId: string, name: string) => Promise<TableViewViewModel>
67
+ /** Delete a view. Clears active view if it was the deleted one. */
68
+ deleteView: (viewId: string) => Promise<void>
69
+ }
70
+
71
+ const STORAGE_PREFIX = 'pxl.tableView:'
72
+
73
+ function getStorageKey(model: string): string {
74
+ return `${STORAGE_PREFIX}${model}`
75
+ }
76
+
77
+ function readPersistedViewId(model: string): string | null {
78
+ try {
79
+ return localStorage.getItem(getStorageKey(model))
80
+ } catch {
81
+ return null
82
+ }
83
+ }
84
+
85
+ function persistViewId(model: string, viewId: string | null): void {
86
+ try {
87
+ const key = getStorageKey(model)
88
+ if (viewId) {
89
+ localStorage.setItem(key, viewId)
90
+ } else {
91
+ localStorage.removeItem(key)
92
+ }
93
+ } catch {
94
+ // ignore storage errors
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Hook that wraps the generated `useTableViews` CRUD hook and adds:
100
+ * - Scoping by model name and current user
101
+ * - JSON parsing/serialization for table state fields
102
+ * - Active view tracking (persisted to localStorage)
103
+ * - Auto-restore of previously active view on mount
104
+ * - Simplified save/update/rename/delete API
105
+ */
106
+ export function useTableViewConfig(options: UseTableViewConfigOptions): UseTableViewConfigResult {
107
+ const { model, currentUserId } = options
108
+ const tableViews = useTableViews()
109
+ const [activeViewId, setActiveViewIdState] = useState<string | null>(() => readPersistedViewId(model))
110
+ const hasRestoredRef = useRef(false)
111
+
112
+ // Persist active view ID to localStorage whenever it changes
113
+ const setActiveViewId = useCallback(
114
+ (id: string | null) => {
115
+ setActiveViewIdState(id)
116
+ persistViewId(model, id)
117
+ },
118
+ [model],
119
+ )
120
+
121
+ // Filter views: only this model, and only the user's own or global views
122
+ const views = useMemo(
123
+ (): TableViewViewModel[] =>
124
+ tableViews.list.filter((v) => v.model === model && (v.isGlobal || v.userId === currentUserId)),
125
+ [tableViews.list, model, currentUserId],
126
+ )
127
+
128
+ // Map to the shape expected by DataGridViewMenu
129
+ const savedViews = useMemo(
130
+ () => views.map((v) => ({ id: v.id, name: v.name, isGlobal: v.isGlobal, userId: v.userId })),
131
+ [views],
132
+ )
133
+
134
+ // Once views are loaded, validate the persisted view ID still exists.
135
+ // If the persisted view was deleted, clear it.
136
+ useEffect(() => {
137
+ if (!tableViews.isLoaded || hasRestoredRef.current) return
138
+ hasRestoredRef.current = true
139
+
140
+ if (activeViewId && !views.find((v) => v.id === activeViewId)) {
141
+ setActiveViewId(null)
142
+ }
143
+ }, [tableViews.isLoaded, views, activeViewId, setActiveViewId])
144
+
145
+ const parseViewState = useCallback((view: TableViewViewModel): TableViewState => {
146
+ const decode = <T,>(json: string | null | undefined, decoder: z.ZodType<T>): T | undefined => {
147
+ if (!json) {
148
+ return undefined
149
+ }
150
+ const result = decoder.safeParse(json)
151
+ return result.success ? result.data : undefined
152
+ }
153
+ return {
154
+ filters: decode(view.filters, filtersDecoder),
155
+ sorting: decode(view.sorting, sortingDecoder),
156
+ columnVisibility: decode(view.columnVisibility, columnVisibilityDecoder),
157
+ columnOrder: decode(view.columnOrder, columnOrderDecoder),
158
+ columnPinning: decode(view.columnPinning, columnPinningDecoder),
159
+ columnSizing: decode(view.columnSizing, columnSizingDecoder),
160
+ }
161
+ }, [])
162
+
163
+ const getActiveViewState = useCallback((): TableViewState | null => {
164
+ if (!activeViewId) return null
165
+ const view = views.find((v) => v.id === activeViewId)
166
+ if (!view) return null
167
+ return parseViewState(view)
168
+ }, [activeViewId, views, parseViewState])
169
+
170
+ const serializeState = useCallback(
171
+ (state: TableViewState) => ({
172
+ filters: state.filters ? JSON.stringify(state.filters) : null,
173
+ sorting: state.sorting ? JSON.stringify(state.sorting) : null,
174
+ columnVisibility: state.columnVisibility ? JSON.stringify(state.columnVisibility) : null,
175
+ columnOrder: state.columnOrder ? JSON.stringify(state.columnOrder) : null,
176
+ columnPinning: state.columnPinning ? JSON.stringify(state.columnPinning) : null,
177
+ columnSizing: state.columnSizing ? JSON.stringify(state.columnSizing) : null,
178
+ }),
179
+ [],
180
+ )
181
+
182
+ const saveView = useCallback(
183
+ async (args: { name: string; isGlobal?: boolean; state: TableViewState }) => {
184
+ if (!currentUserId) {
185
+ throw new Error('Cannot save a view without a currentUserId')
186
+ }
187
+ const result = await tableViews.create({
188
+ name: args.name,
189
+ model,
190
+ userId: currentUserId as TableViewViewModel['userId'],
191
+ isGlobal: args.isGlobal ?? false,
192
+ ...serializeState(args.state),
193
+ })
194
+ setActiveViewId(result.id)
195
+ return result
196
+ },
197
+ [tableViews, model, currentUserId, serializeState, setActiveViewId],
198
+ )
199
+
200
+ const updateView = useCallback(
201
+ async (viewId: string, state: TableViewState) => {
202
+ const existing = views.find((v) => v.id === viewId)
203
+ if (!existing) throw new Error(`View ${viewId} not found`)
204
+ return tableViews.update({
205
+ id: viewId as TableViewId,
206
+ name: existing.name,
207
+ model: existing.model,
208
+ userId: existing.userId,
209
+ isGlobal: existing.isGlobal,
210
+ ...serializeState(state),
211
+ })
212
+ },
213
+ [tableViews, views, serializeState],
214
+ )
215
+
216
+ const renameView = useCallback(
217
+ async (viewId: string, name: string) => {
218
+ return tableViews.updateField({ id: viewId as TableViewId, field: 'name', value: name })
219
+ },
220
+ [tableViews],
221
+ )
222
+
223
+ const deleteView = useCallback(
224
+ async (viewId: string) => {
225
+ await tableViews.delete(viewId as TableViewId)
226
+ if (activeViewId === viewId) {
227
+ setActiveViewId(null)
228
+ }
229
+ },
230
+ [tableViews, activeViewId, setActiveViewId],
231
+ )
232
+
233
+ const clearActiveView = useCallback(() => setActiveViewId(null), [setActiveViewId])
234
+
235
+ return {
236
+ views,
237
+ savedViews,
238
+ isLoaded: tableViews.isLoaded,
239
+ activeViewId,
240
+ setActiveViewId,
241
+ clearActiveView,
242
+ parseViewState,
243
+ getActiveViewState,
244
+ saveView,
245
+ updateView,
246
+ renameView,
247
+ deleteView,
248
+ }
249
+ }
@@ -16,7 +16,7 @@ import { APP_CONFIG } from '@lib/config'
16
16
  import { Button, ContentFrame } from '@postxl/ui-components'
17
17
 
18
18
  export const DashboardPage = () => {
19
- const { logout, user } = useAuth()
19
+ const { logout, viewerData } = useAuth()
20
20
 
21
21
  const [selectedPost, setSelectedPost] = useState<Post | null>(null)
22
22
  const [isUpdateModalOpen, setIsUpdateModalOpen] = useState(false)
@@ -42,16 +42,16 @@ export const DashboardPage = () => {
42
42
  controls={[
43
43
  <ColorModeToggle key="btn-color-mode" onClick={onToggleTheme} __e2e_test_id__="button-toggle-theme" />,
44
44
  APP_CONFIG.AUTH && (
45
- <Button key="btn-logout" onClick={logout}>
45
+ <Button key="btn-logout" onClick={() => logout()}>
46
46
  <PersonIcon /> Logout
47
47
  </Button>
48
48
  ),
49
49
  ]}
50
50
  >
51
51
  <div className="p-3">
52
- <h3 className="mb-4">Hi {user?.userDetails?.name}!</h3>
53
- <p>eMail: {user?.userDetails?.email}</p>
54
- <p>roles: {user?.userRoles.join(', ')}</p>
52
+ <h3 className="mb-4">Hi {viewerData?.user?.name}!</h3>
53
+ <p>eMail: {viewerData?.user?.email}</p>
54
+ <p>roles: {viewerData?.userRoles.join(', ')}</p>
55
55
  </div>
56
56
  <div className="p-3">
57
57
  <h3 className="mb-4">Posts:</h3>
@@ -0,0 +1,37 @@
1
+ import { AuthErrorType, useAuth } from '@context-providers/auth-context-provider'
2
+
3
+ import { Button, InfoCard } from '@postxl/ui-components'
4
+
5
+ const errorMessages: Record<AuthErrorType, { title: string; message: string }> = {
6
+ errorInit: {
7
+ title: 'Authentication Failed',
8
+ message: 'Failed to initialize the authentication system. Please try logging in again.',
9
+ },
10
+ errorLoadUser: {
11
+ title: 'Unable to Load User Data',
12
+ message: 'There was an error loading your user information. Please try logging in again.',
13
+ },
14
+ errorTokenRefresh: {
15
+ title: 'Session Expired',
16
+ message: 'Your session could not be refreshed. Please log in again to continue.',
17
+ },
18
+ }
19
+
20
+ export const AuthErrorPage = ({ errorType }: { errorType?: AuthErrorType }) => {
21
+ const errorInfo = errorMessages[errorType ?? 'errorInit']
22
+ const { logout } = useAuth()
23
+
24
+ return (
25
+ <InfoCard variant="error" title={errorInfo.title} message={errorInfo.message}>
26
+ <div className="flex gap-2">
27
+ <Button asChild>
28
+ {/* we don't use a tanstack link here to force a page reload. So all auth states will be re-initialized */}
29
+ <a href="/">Try Again</a>
30
+ </Button>
31
+ <Button variant="secondary" onClick={() => void logout({ redirectUri: window.location.origin })}>
32
+ Login Again
33
+ </Button>
34
+ </div>
35
+ </InfoCard>
36
+ )
37
+ }