@postxl/generators 1.11.6 → 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.
- package/dist/backend-view/model-view-service.generator.js +4 -1
- package/dist/backend-view/model-view-service.generator.js.map +1 -1
- package/dist/backend-view/template/query.utils.test.ts +34 -0
- package/dist/backend-view/template/query.utils.ts +89 -0
- package/dist/frontend-admin/admin.generator.d.ts +9 -0
- package/dist/frontend-admin/admin.generator.js +19 -0
- package/dist/frontend-admin/admin.generator.js.map +1 -1
- package/dist/frontend-admin/generators/admin-sidebar.generator.js +1 -1
- package/dist/frontend-admin/generators/audit-log-sidebar.generator.js +107 -113
- package/dist/frontend-admin/generators/audit-log-sidebar.generator.js.map +1 -1
- package/dist/frontend-admin/generators/comment-sidebar.generator.d.ts +9 -0
- package/dist/frontend-admin/generators/comment-sidebar.generator.js +246 -0
- package/dist/frontend-admin/generators/comment-sidebar.generator.js.map +1 -0
- package/dist/frontend-admin/generators/detail-sidebar.generator.d.ts +9 -0
- package/dist/frontend-admin/generators/detail-sidebar.generator.js +148 -0
- package/dist/frontend-admin/generators/detail-sidebar.generator.js.map +1 -0
- package/dist/frontend-admin/generators/model-admin-page.generator.js +40 -6
- package/dist/frontend-admin/generators/model-admin-page.generator.js.map +1 -1
- package/dist/frontend-core/frontend.generator.js +2 -1
- package/dist/frontend-core/frontend.generator.js.map +1 -1
- package/dist/frontend-core/template/docs/LOGIN_PROCESS.d2 +372 -0
- package/dist/frontend-core/template/docs/LOGIN_PROCESS.md +214 -0
- package/dist/frontend-core/template/docs/LOGIN_PROCESS.svg +914 -0
- package/dist/frontend-core/template/src/components/admin/table-filter.stories.tsx +265 -0
- package/dist/frontend-core/template/src/components/admin/table-filter.tsx +224 -128
- package/dist/frontend-core/template/src/components/admin/table-view-panel.tsx +159 -0
- package/dist/frontend-core/template/src/context-providers/auth-context-provider.tsx +54 -43
- package/dist/frontend-core/template/src/hooks/use-table-view-config.tsx +249 -0
- package/dist/frontend-core/template/src/pages/dashboard/dashboard.page.tsx +5 -5
- package/dist/frontend-core/template/src/pages/error/auth-error.page.tsx +37 -0
- package/dist/frontend-core/template/src/pages/error/default-error.page.tsx +24 -40
- package/dist/frontend-core/template/src/pages/error/not-found-error.page.tsx +8 -10
- package/dist/frontend-core/template/src/pages/login/login.page.tsx +39 -13
- package/dist/frontend-core/template/src/pages/unauthorized/unauthorized.page.tsx +2 -2
- package/dist/frontend-core/template/src/routes/_auth-routes.tsx +21 -8
- package/dist/frontend-core/template/src/routes/auth-error.tsx +19 -0
- package/dist/frontend-core/template/vite.config.ts +5 -0
- package/dist/frontend-core/types/hook.d.ts +1 -1
- package/dist/frontend-tables/generators/model-table.generator.js +30 -5
- package/dist/frontend-tables/generators/model-table.generator.js.map +1 -1
- package/dist/types/template/query.types.ts +19 -1
- package/package.json +5 -5
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type {
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
21
|
-
isInitComplete: boolean
|
|
22
|
+
loginState: AuthState | AuthErrorType
|
|
22
23
|
login: () => Promise<void>
|
|
23
|
-
logout: () => Promise<void>
|
|
24
|
-
|
|
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 [
|
|
76
|
-
const [
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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 (
|
|
149
|
-
|
|
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
|
|
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
|
|
158
|
-
|
|
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
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
}, [
|
|
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 (
|
|
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
|
-
|
|
192
|
-
void logout() // NOSONAR
|
|
198
|
+
setLoginState('errorTokenRefresh')
|
|
193
199
|
}
|
|
194
200
|
}, 5000)
|
|
195
201
|
|
|
196
202
|
return () => clearInterval(refreshIntervalId)
|
|
197
|
-
}, [
|
|
203
|
+
}, [loginState])
|
|
198
204
|
|
|
199
205
|
const memoizedValues = useMemo(
|
|
200
|
-
() => ({
|
|
201
|
-
|
|
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,
|
|
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?.
|
|
53
|
-
<p>eMail: {user?.
|
|
54
|
-
<p>roles: {
|
|
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
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { type ErrorComponentProps
|
|
1
|
+
import { type ErrorComponentProps } from '@tanstack/react-router'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { InfoCard } from '@postxl/ui-components'
|
|
4
4
|
|
|
5
5
|
const isDev = import.meta.env.DEV
|
|
6
6
|
|
|
@@ -10,44 +10,28 @@ export const DefaultErrorPage = ({ error }: { error: ErrorComponentProps }) => {
|
|
|
10
10
|
const errorStack = actualError instanceof Error ? actualError.stack : undefined
|
|
11
11
|
|
|
12
12
|
return (
|
|
13
|
-
<
|
|
14
|
-
|
|
15
|
-
<
|
|
16
|
-
<
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
<
|
|
20
|
-
|
|
21
|
-
</
|
|
13
|
+
<InfoCard variant="error" title="An unexpected error occurred" message={errorMessage} showHomeButton>
|
|
14
|
+
{isDev && errorStack && (
|
|
15
|
+
<details>
|
|
16
|
+
<summary className="cursor-pointer text-sm text-muted-foreground hover:text-foreground">
|
|
17
|
+
Show stack trace
|
|
18
|
+
</summary>
|
|
19
|
+
<pre className="mt-2 p-4 bg-muted rounded-md overflow-auto text-xs font-mono whitespace-pre-wrap break-words max-h-96">
|
|
20
|
+
{errorStack}
|
|
21
|
+
</pre>
|
|
22
|
+
</details>
|
|
23
|
+
)}
|
|
22
24
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
{isDev && (
|
|
35
|
-
<details>
|
|
36
|
-
<summary className="cursor-pointer text-sm text-muted-foreground hover:text-foreground">
|
|
37
|
-
Show raw error data
|
|
38
|
-
</summary>
|
|
39
|
-
<pre className="mt-2 p-4 bg-muted rounded-md overflow-auto text-xs font-mono whitespace-pre-wrap break-words max-h-64">
|
|
40
|
-
{JSON.stringify(error, null, 2)}
|
|
41
|
-
</pre>
|
|
42
|
-
</details>
|
|
43
|
-
)}
|
|
44
|
-
</CardContent>
|
|
45
|
-
<CardFooter>
|
|
46
|
-
<Button asChild>
|
|
47
|
-
<Link to={'/'}>Back to Home</Link>
|
|
48
|
-
</Button>
|
|
49
|
-
</CardFooter>
|
|
50
|
-
</Card>
|
|
51
|
-
</div>
|
|
25
|
+
{isDev && (
|
|
26
|
+
<details>
|
|
27
|
+
<summary className="cursor-pointer text-sm text-muted-foreground hover:text-foreground">
|
|
28
|
+
Show raw error data
|
|
29
|
+
</summary>
|
|
30
|
+
<pre className="mt-2 p-4 bg-muted rounded-md overflow-auto text-xs font-mono whitespace-pre-wrap break-words max-h-64">
|
|
31
|
+
{JSON.stringify(error, null, 2)}
|
|
32
|
+
</pre>
|
|
33
|
+
</details>
|
|
34
|
+
)}
|
|
35
|
+
</InfoCard>
|
|
52
36
|
)
|
|
53
37
|
}
|
|
@@ -1,17 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { NotFoundError } from '@tanstack/react-router'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { InfoCard } from '@postxl/ui-components'
|
|
4
4
|
|
|
5
5
|
export const NotFoundErrorPage = ({ error }: { error: NotFoundError }) => {
|
|
6
6
|
console.log(error)
|
|
7
7
|
return (
|
|
8
|
-
<
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
</div>
|
|
15
|
-
</div>
|
|
8
|
+
<InfoCard
|
|
9
|
+
variant="not-found"
|
|
10
|
+
title="Page Not Found"
|
|
11
|
+
message="The page you're looking for doesn't exist."
|
|
12
|
+
showHomeButton
|
|
13
|
+
/>
|
|
16
14
|
)
|
|
17
15
|
}
|