@nixxie-cms/auth 1.0.1

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 (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -0
  3. package/components/Navigation/dist/nixxie-cms-auth-components-Navigation.cjs.d.ts +3 -0
  4. package/components/Navigation/dist/nixxie-cms-auth-components-Navigation.cjs.js +147 -0
  5. package/components/Navigation/dist/nixxie-cms-auth-components-Navigation.esm.js +143 -0
  6. package/components/Navigation/package.json +4 -0
  7. package/dist/declarations/src/components/Navigation.d.ts +6 -0
  8. package/dist/declarations/src/components/Navigation.d.ts.map +1 -0
  9. package/dist/declarations/src/index.d.ts +15 -0
  10. package/dist/declarations/src/index.d.ts.map +1 -0
  11. package/dist/declarations/src/pages/InitPage.d.ts +9 -0
  12. package/dist/declarations/src/pages/InitPage.d.ts.map +1 -0
  13. package/dist/declarations/src/pages/SigninPage.d.ts +9 -0
  14. package/dist/declarations/src/pages/SigninPage.d.ts.map +1 -0
  15. package/dist/declarations/src/types.d.ts +49 -0
  16. package/dist/declarations/src/types.d.ts.map +1 -0
  17. package/dist/nixxie-cms-auth.cjs.d.ts +2 -0
  18. package/dist/nixxie-cms-auth.cjs.js +552 -0
  19. package/dist/nixxie-cms-auth.esm.js +548 -0
  20. package/dist/useFromRedirect-2de239a9.cjs.js +26 -0
  21. package/dist/useFromRedirect-b3deee00.esm.js +24 -0
  22. package/package.json +56 -0
  23. package/pages/InitPage/dist/nixxie-cms-auth-pages-InitPage.cjs.d.ts +3 -0
  24. package/pages/InitPage/dist/nixxie-cms-auth-pages-InitPage.cjs.js +274 -0
  25. package/pages/InitPage/dist/nixxie-cms-auth-pages-InitPage.esm.js +266 -0
  26. package/pages/InitPage/package.json +4 -0
  27. package/pages/SigninPage/dist/nixxie-cms-auth-pages-SigninPage.cjs.d.ts +3 -0
  28. package/pages/SigninPage/dist/nixxie-cms-auth-pages-SigninPage.cjs.js +319 -0
  29. package/pages/SigninPage/dist/nixxie-cms-auth-pages-SigninPage.esm.js +311 -0
  30. package/pages/SigninPage/package.json +4 -0
  31. package/src/components/Navigation.tsx +182 -0
  32. package/src/gql/getBaseAuthSchema.ts +129 -0
  33. package/src/gql/getInitFirstItemSchema.ts +87 -0
  34. package/src/index.ts +291 -0
  35. package/src/lib/useFromRedirect.ts +23 -0
  36. package/src/pages/InitPage.tsx +292 -0
  37. package/src/pages/SigninPage.tsx +331 -0
  38. package/src/schema.ts +84 -0
  39. package/src/templates/config.ts +9 -0
  40. package/src/templates/init.ts +22 -0
  41. package/src/templates/signin.ts +20 -0
  42. package/src/types.ts +57 -0
@@ -0,0 +1,182 @@
1
+ import { useEffect, useMemo } from 'react'
2
+
3
+ import { Divider } from '@keystar/ui/layout'
4
+ import { css } from '@keystar/ui/style'
5
+
6
+ import {
7
+ useQuery,
8
+ useMutation,
9
+ gql,
10
+ type TypedDocumentNode,
11
+ } from '@nixxie-cms/core/admin-ui/apollo'
12
+ import {
13
+ DeveloperResourcesMenu,
14
+ NavList,
15
+ NavContainer,
16
+ NavFooter,
17
+ NavItem,
18
+ getHrefFromList,
19
+ } from '@nixxie-cms/core/admin-ui/components'
20
+ import type { NavigationProps } from '@nixxie-cms/core/admin-ui/components'
21
+ import { useRouter } from '@nixxie-cms/core/admin-ui/router'
22
+
23
+ export default ({ labelField }: { labelField: string }) =>
24
+ (props: NavigationProps) => <Navigation labelField={labelField} {...props} />
25
+
26
+ function Navigation({
27
+ labelField,
28
+ lists,
29
+ }: {
30
+ labelField: string
31
+ } & NavigationProps) {
32
+ const { data } = useQuery<{
33
+ authenticatedItem: null | {
34
+ label: string
35
+ }
36
+ }>(
37
+ useMemo(
38
+ () => gql`
39
+ query NxAuthFetchSession {
40
+ authenticatedItem {
41
+ label: ${labelField}
42
+ }
43
+ }
44
+ `,
45
+ [labelField]
46
+ )
47
+ )
48
+
49
+ return (
50
+ <NavContainer>
51
+ <NavList>
52
+ <NavItem href="/">Dashboard</NavItem>
53
+ <Divider />
54
+ {lists.map(list => (
55
+ <NavItem key={list.key} href={getHrefFromList(list)}>
56
+ {list.label}
57
+ </NavItem>
58
+ ))}
59
+ </NavList>
60
+
61
+ <NavFooter>
62
+ {data?.authenticatedItem && (
63
+ <UserFooter authItemLabel={data.authenticatedItem.label} />
64
+ )}
65
+ <DeveloperResourcesMenu />
66
+ </NavFooter>
67
+ </NavContainer>
68
+ )
69
+ }
70
+
71
+ const END_SESSION = gql`
72
+ mutation NxAuthEndSession {
73
+ endSession
74
+ }
75
+ ` as TypedDocumentNode<{ endSession: boolean }>
76
+
77
+ function UserFooter({ authItemLabel }: { authItemLabel: string }) {
78
+ const router = useRouter()
79
+ const [endSession, { data }] = useMutation(END_SESSION)
80
+
81
+ useEffect(() => {
82
+ if (data?.endSession) {
83
+ router.push('/signin')
84
+ }
85
+ }, [data])
86
+
87
+ const initials = authItemLabel
88
+ .split(' ')
89
+ .slice(0, 2)
90
+ .map(s => s.charAt(0).toUpperCase())
91
+ .join('')
92
+
93
+ return (
94
+ <div
95
+ className={css({
96
+ display: 'flex',
97
+ alignItems: 'center',
98
+ gap: 8,
99
+ paddingInline: '4px',
100
+ paddingBlock: '2px',
101
+ })}
102
+ >
103
+ {/* Avatar */}
104
+ <span
105
+ className={css({
106
+ display: 'inline-flex',
107
+ alignItems: 'center',
108
+ justifyContent: 'center',
109
+ width: 28,
110
+ height: 28,
111
+ borderRadius: '50%',
112
+ backgroundColor: '#0a0a0a',
113
+ color: '#ffffff',
114
+ fontSize: 10.5,
115
+ fontWeight: 600,
116
+ letterSpacing: '0.02em',
117
+ flexShrink: 0,
118
+ fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
119
+ })}
120
+ >
121
+ {initials}
122
+ </span>
123
+
124
+ {/* Name */}
125
+ <span
126
+ className={css({
127
+ flex: 1,
128
+ fontSize: 12.5,
129
+ fontWeight: 500,
130
+ color: '#2a2a2a',
131
+ overflow: 'hidden',
132
+ textOverflow: 'ellipsis',
133
+ whiteSpace: 'nowrap',
134
+ lineHeight: 1.2,
135
+ })}
136
+ >
137
+ {authItemLabel}
138
+ </span>
139
+
140
+ {/* Sign out icon button */}
141
+ <button
142
+ onClick={() => endSession()}
143
+ title="Sign out"
144
+ aria-label="Sign out"
145
+ className={css({
146
+ display: 'inline-flex',
147
+ alignItems: 'center',
148
+ justifyContent: 'center',
149
+ width: 26,
150
+ height: 26,
151
+ border: '1px solid #ebebeb',
152
+ borderRadius: 6,
153
+ background: 'transparent',
154
+ cursor: 'pointer',
155
+ color: '#a3a3a3',
156
+ flexShrink: 0,
157
+ transition: 'color 130ms, border-color 130ms, background 130ms',
158
+ '&:hover': {
159
+ color: '#0a0a0a',
160
+ borderColor: '#c8c8c8',
161
+ background: '#f5f5f5',
162
+ },
163
+ '&:focus-visible': {
164
+ outline: '2px solid #000',
165
+ outlineOffset: 2,
166
+ },
167
+ })}
168
+ >
169
+ {/* Arrow-right-from-bracket (sign out) */}
170
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none">
171
+ <path
172
+ d="M4.5 2H2.5C2.22386 2 2 2.22386 2 2.5V9.5C2 9.77614 2.22386 10 2.5 10H4.5M8 4L10 6L8 8M10 6H4.5"
173
+ stroke="currentColor"
174
+ strokeWidth="1.25"
175
+ strokeLinecap="round"
176
+ strokeLinejoin="round"
177
+ />
178
+ </svg>
179
+ </button>
180
+ </div>
181
+ )
182
+ }
@@ -0,0 +1,129 @@
1
+ import type { BaseItem, NixxieContext } from '@nixxie-cms/core/types'
2
+ import { g } from '@nixxie-cms/core'
3
+ import { getPasswordFieldKDF } from '@nixxie-cms/core/fields/types/password'
4
+ import type { AuthGqlNames } from '../types'
5
+ import type { BaseSchemaMeta } from '@nixxie-cms/core/graphql-ts'
6
+
7
+ const AUTHENTICATION_FAILURE = {
8
+ code: 'FAILURE',
9
+ message: 'Authentication failed.',
10
+ } as const
11
+
12
+ export function getBaseAuthSchema<I extends string, S extends string>({
13
+ authGqlNames,
14
+ listKey,
15
+ identityField,
16
+ secretField,
17
+ base,
18
+ }: {
19
+ authGqlNames: AuthGqlNames
20
+ listKey: string
21
+ identityField: I
22
+ secretField: S
23
+ base: BaseSchemaMeta
24
+ }) {
25
+ const kdf = getPasswordFieldKDF(base.schema, listKey, secretField)
26
+ if (!kdf) {
27
+ throw new Error(`${listKey}.${secretField} is not a valid password field.`)
28
+ }
29
+
30
+ const ItemAuthenticationWithPasswordSuccess = g.object<{
31
+ sessionToken: string
32
+ item: BaseItem
33
+ }>()({
34
+ name: authGqlNames.ItemAuthenticationWithPasswordSuccess,
35
+ fields: {
36
+ sessionToken: g.field({ type: g.nonNull(g.String) }),
37
+ item: g.field({ type: g.nonNull(base.object(listKey)) }),
38
+ },
39
+ })
40
+ const ItemAuthenticationWithPasswordFailure = g.object<{ message: string }>()({
41
+ name: authGqlNames.ItemAuthenticationWithPasswordFailure,
42
+ fields: {
43
+ message: g.field({ type: g.nonNull(g.String) }),
44
+ },
45
+ })
46
+ const AuthenticationResult = g.union({
47
+ name: authGqlNames.ItemAuthenticationWithPasswordResult,
48
+ types: [ItemAuthenticationWithPasswordSuccess, ItemAuthenticationWithPasswordFailure],
49
+ resolveType(val) {
50
+ if ('sessionToken' in val) return authGqlNames.ItemAuthenticationWithPasswordSuccess
51
+ return authGqlNames.ItemAuthenticationWithPasswordFailure
52
+ },
53
+ })
54
+
55
+ const extension = {
56
+ query: {
57
+ authenticatedItem: g.field({
58
+ type: base.object(listKey),
59
+ resolve(rootVal, args, context: NixxieContext) {
60
+ const { session } = context
61
+ if (!session?.itemId) return null
62
+
63
+ return context.db[listKey].findOne({
64
+ where: {
65
+ id: session.itemId,
66
+ },
67
+ })
68
+ },
69
+ }),
70
+ },
71
+ mutation: {
72
+ endSession: g.field({
73
+ type: g.nonNull(g.Boolean),
74
+ async resolve(rootVal, args, context) {
75
+ await context.sessionStrategy?.end({ context })
76
+ return true
77
+ },
78
+ }),
79
+ [authGqlNames.authenticateItemWithPassword]: g.field({
80
+ type: AuthenticationResult,
81
+ args: {
82
+ [identityField]: g.arg({ type: g.nonNull(g.String) }),
83
+ [secretField]: g.arg({ type: g.nonNull(g.String) }),
84
+ },
85
+ async resolve(
86
+ rootVal,
87
+ { [identityField]: identity, [secretField]: secret },
88
+ context: NixxieContext
89
+ ) {
90
+ if (!context.sessionStrategy) throw new Error('No session strategy on context')
91
+
92
+ const item = await context.sudo().db[listKey].findOne({
93
+ where: { [identityField]: identity },
94
+ })
95
+
96
+ if (typeof item?.[secretField] !== 'string') {
97
+ await kdf.hash('simulated-password-to-counter-timing-attack')
98
+ return AUTHENTICATION_FAILURE
99
+ }
100
+
101
+ const equal = await kdf.compare(secret, item[secretField])
102
+ if (!equal) return AUTHENTICATION_FAILURE
103
+
104
+ const sessionToken = await context.sessionStrategy.start({
105
+ data: {
106
+ listKey,
107
+ itemId: item.id,
108
+ },
109
+ context,
110
+ })
111
+
112
+ if (typeof sessionToken !== 'string' || sessionToken.length === 0) {
113
+ return AUTHENTICATION_FAILURE
114
+ }
115
+
116
+ return {
117
+ sessionToken,
118
+ item,
119
+ }
120
+ },
121
+ }),
122
+ },
123
+ }
124
+
125
+ return {
126
+ extension,
127
+ ItemAuthenticationWithPasswordSuccess,
128
+ }
129
+ }
@@ -0,0 +1,87 @@
1
+ import type { BaseItem, NixxieContext } from '@nixxie-cms/core/types'
2
+ import { g } from '@nixxie-cms/core'
3
+ import { assertInputObjectType, GraphQLInputObjectType, type GraphQLSchema } from 'graphql'
4
+ import { type AuthGqlNames, type InitFirstItemConfig } from '../types'
5
+ import type { Extension } from '@nixxie-cms/core/graphql-ts'
6
+
7
+ const AUTHENTICATION_FAILURE = 'Authentication failed.' as const
8
+
9
+ export function getInitFirstItemSchema({
10
+ authGqlNames,
11
+ listKey,
12
+ fields,
13
+ defaultItemData,
14
+ graphQLSchema,
15
+ ItemAuthenticationWithPasswordSuccess,
16
+ }: {
17
+ authGqlNames: AuthGqlNames
18
+ listKey: string
19
+ fields: InitFirstItemConfig<any>['fields']
20
+ defaultItemData: InitFirstItemConfig<any>['itemData']
21
+ graphQLSchema: GraphQLSchema
22
+ ItemAuthenticationWithPasswordSuccess: g<
23
+ typeof g.object<{
24
+ item: BaseItem
25
+ sessionToken: string
26
+ }>
27
+ >
28
+ // TODO: return type required by pnpm :(
29
+ }): Extension {
30
+ const createInputConfig = assertInputObjectType(
31
+ graphQLSchema.getType(`${listKey}CreateInput`)
32
+ ).toConfig()
33
+ const fieldsSet = new Set(fields)
34
+ const initialCreateInput = new GraphQLInputObjectType({
35
+ ...createInputConfig,
36
+ fields: Object.fromEntries(
37
+ Object.entries(createInputConfig.fields).filter(([fieldKey]) => fieldsSet.has(fieldKey))
38
+ ),
39
+ name: authGqlNames.CreateInitialInput,
40
+ })
41
+
42
+ return {
43
+ mutation: {
44
+ [authGqlNames.createInitialItem]: g.field({
45
+ type: g.nonNull(ItemAuthenticationWithPasswordSuccess),
46
+ args: { data: g.arg({ type: g.nonNull(initialCreateInput) }) },
47
+ async resolve(rootVal, { data }, context: NixxieContext) {
48
+ if (!context.sessionStrategy) throw new Error('No session strategy on context')
49
+
50
+ const sudoContext = context.sudo()
51
+
52
+ // should approximate hasInitFirstItemConditions
53
+ const count = await sudoContext.db[listKey].count()
54
+ if (count !== 0) throw AUTHENTICATION_FAILURE
55
+
56
+ // Update system state
57
+ // this is strictly speaking incorrect. the db API will do GraphQL coercion on a value which has already been coerced
58
+ // (this is also mostly fine, the chance that people are using things where
59
+ // the input value can't round-trip like the Upload scalar here is quite low)
60
+ const item = await sudoContext.db[listKey].createOne({
61
+ data: {
62
+ ...defaultItemData,
63
+ ...data,
64
+ },
65
+ })
66
+
67
+ const sessionToken = await context.sessionStrategy.start({
68
+ data: {
69
+ listKey,
70
+ itemId: item.id,
71
+ },
72
+ context,
73
+ })
74
+
75
+ if (typeof sessionToken !== 'string' || sessionToken.length === 0) {
76
+ throw AUTHENTICATION_FAILURE
77
+ }
78
+
79
+ return {
80
+ sessionToken,
81
+ item,
82
+ }
83
+ },
84
+ }),
85
+ },
86
+ }
87
+ }
package/src/index.ts ADDED
@@ -0,0 +1,291 @@
1
+ import type {
2
+ AdminFileToWrite,
3
+ BaseListTypeInfo,
4
+ NixxieContext,
5
+ SessionStrategy,
6
+ BaseNixxieTypeInfo,
7
+ NixxieConfig,
8
+ } from '@nixxie-cms/core/types'
9
+ import type { AuthConfig, AuthGqlNames } from './types'
10
+
11
+ import { getSchemaExtension } from './schema'
12
+ import configTemplate from './templates/config'
13
+ import signinTemplate from './templates/signin'
14
+ import initTemplate from './templates/init'
15
+
16
+ export type AuthSession = {
17
+ itemId: string | number // TODO: use ListTypeInfo
18
+ data: unknown // TODO: use ListTypeInfo
19
+ }
20
+
21
+ function getAuthGqlNames(singular: string): AuthGqlNames {
22
+ const lowerSingularName = singular.charAt(0).toLowerCase() + singular.slice(1)
23
+ return {
24
+ itemQueryName: lowerSingularName,
25
+ whereUniqueInputName: `${singular}WhereUniqueInput`,
26
+
27
+ authenticateItemWithPassword: `authenticate${singular}WithPassword`,
28
+ ItemAuthenticationWithPasswordResult: `${singular}AuthenticationWithPasswordResult`,
29
+ ItemAuthenticationWithPasswordSuccess: `${singular}AuthenticationWithPasswordSuccess`,
30
+ ItemAuthenticationWithPasswordFailure: `${singular}AuthenticationWithPasswordFailure`,
31
+
32
+ CreateInitialInput: `CreateInitial${singular}Input`,
33
+ createInitialItem: `createInitial${singular}`,
34
+ } as const
35
+ }
36
+
37
+ // TODO: use TypeInfo and listKey for types
38
+ /**
39
+ * createAuth function
40
+ *
41
+ * Generates config for Nixxie to implement standard auth features.
42
+ */
43
+ export function createAuth<ListTypeInfo extends BaseListTypeInfo>({
44
+ listKey,
45
+ secretField,
46
+ initFirstItem,
47
+ identityField,
48
+ sessionData = 'id',
49
+ }: AuthConfig<ListTypeInfo>) {
50
+ /**
51
+ * getAdditionalFiles
52
+ *
53
+ * This function adds files to be generated into the Admin UI build. Must be added to the
54
+ * ui.getAdditionalFiles config.
55
+ *
56
+ * The signin page is always included, and the init page is included when initFirstItem is set
57
+ */
58
+ const authGetAdditionalFiles = (config: NixxieConfig) => {
59
+ // TODO: FIXME: this is a duplication of initialise-lists:747
60
+ const listConfig = config.lists[listKey]
61
+ const labelField =
62
+ listConfig.ui?.labelField ??
63
+ (listConfig.fields.label
64
+ ? 'label'
65
+ : listConfig.fields.name
66
+ ? 'name'
67
+ : listConfig.fields.title
68
+ ? 'title'
69
+ : 'id')
70
+
71
+ const authGqlNames = getAuthGqlNames(listConfig.graphql?.singular ?? listKey)
72
+ const filesToWrite: AdminFileToWrite[] = [
73
+ {
74
+ mode: 'write',
75
+ src: signinTemplate({ authGqlNames, identityField, secretField }),
76
+ outputPath: 'pages/signin.js',
77
+ },
78
+ {
79
+ mode: 'write',
80
+ src: configTemplate({ labelField }),
81
+ outputPath: 'config.ts',
82
+ },
83
+ ]
84
+ if (initFirstItem) {
85
+ filesToWrite.push({
86
+ mode: 'write',
87
+ src: initTemplate({ authGqlNames, listKey, initFirstItem }),
88
+ outputPath: 'pages/init.js',
89
+ })
90
+ }
91
+ return filesToWrite
92
+ }
93
+
94
+ function throwIfInvalidConfig<TypeInfo extends BaseNixxieTypeInfo>(
95
+ config: NixxieConfig<TypeInfo>
96
+ ) {
97
+ if (!(listKey in config.lists)) {
98
+ throw new Error(`withAuth cannot find the list "${listKey}"`)
99
+ }
100
+
101
+ // TODO: verify that the identity field is unique
102
+ // TODO: verify that the field is required
103
+ const list = config.lists[listKey]
104
+ if (!(identityField in list.fields)) {
105
+ throw new Error(`withAuth cannot find the identity field "${listKey}.${identityField}"`)
106
+ }
107
+
108
+ if (!(secretField in list.fields)) {
109
+ throw new Error(`withAuth cannot find the secret field "${listKey}.${secretField}"`)
110
+ }
111
+
112
+ for (const fieldKey of initFirstItem?.fields || []) {
113
+ if (fieldKey in list.fields) continue
114
+
115
+ throw new Error(`initFirstItem.fields has unknown field "${listKey}.${fieldKey}"`)
116
+ }
117
+ }
118
+
119
+ // this strategy wraps the existing session strategy,
120
+ // and injects the requested session.data before returning
121
+ function authSessionStrategy<Session extends AuthSession>(
122
+ _sessionStrategy: SessionStrategy<Session>
123
+ ): SessionStrategy<Session> {
124
+ const { get, ...sessionStrategy } = _sessionStrategy
125
+ return {
126
+ ...sessionStrategy,
127
+ get: async ({ context }) => {
128
+ const session = await get({ context })
129
+ const sudoContext = context.sudo()
130
+ if (!session?.itemId) return
131
+
132
+ // TODO: replace with SessionSecret: HMAC({ listKey, identityField, secretField }, SessionSecretVar)
133
+ // if (session.listKey !== listKey) return null
134
+
135
+ try {
136
+ const data = await sudoContext.query[listKey].findOne({
137
+ where: { id: session.itemId },
138
+ query: sessionData,
139
+ })
140
+ if (!data) return
141
+
142
+ return {
143
+ ...session,
144
+ itemId: session.itemId,
145
+ data,
146
+ }
147
+ } catch (e) {
148
+ console.error(e)
149
+ // WARNING: this is probably an invalid configuration
150
+ return
151
+ }
152
+ },
153
+ }
154
+ }
155
+
156
+ async function hasInitFirstItemConditions<TypeInfo extends BaseNixxieTypeInfo>(
157
+ context: NixxieContext<TypeInfo>
158
+ ) {
159
+ // do nothing if they aren't using this feature
160
+ if (!initFirstItem) return false
161
+
162
+ // if they have a session, there is no initialisation necessary
163
+ if (context.session) return false
164
+
165
+ const count = await context.sudo().db[listKey].count({})
166
+ return count === 0
167
+ }
168
+
169
+ async function authMiddleware<TypeInfo extends BaseNixxieTypeInfo>({
170
+ context,
171
+ wasAccessAllowed,
172
+ basePath,
173
+ }: {
174
+ context: NixxieContext<TypeInfo>
175
+ wasAccessAllowed: boolean
176
+ basePath: string
177
+ }): Promise<{ kind: 'redirect'; to: string } | void> {
178
+ const { req } = context
179
+ const { pathname } = new URL(req!.url!, 'http://_')
180
+
181
+ // redirect to init if initFirstItem conditions are met
182
+ if (pathname !== `${basePath}/init` && (await hasInitFirstItemConditions(context))) {
183
+ return { kind: 'redirect', to: `${basePath}/init` }
184
+ }
185
+
186
+ // redirect to / if attempting to /init and initFirstItem conditions are not met
187
+ if (pathname === `${basePath}/init` && !(await hasInitFirstItemConditions(context))) {
188
+ return { kind: 'redirect', to: basePath }
189
+ }
190
+
191
+ // don't redirect if we have access
192
+ if (wasAccessAllowed) return
193
+
194
+ // otherwise, redirect to signin
195
+ return { kind: 'redirect', to: `${basePath}/signin` }
196
+ }
197
+
198
+ function defaultIsAccessAllowed({ session }: NixxieContext) {
199
+ return session !== undefined
200
+ }
201
+
202
+ function defaultExtendGraphqlSchema<T>(schema: T) {
203
+ return schema
204
+ }
205
+
206
+ /**
207
+ * withAuth
208
+ *
209
+ * Automatically extends your configuration with a prescriptive implementation.
210
+ */
211
+ function withAuth<TypeInfo extends BaseNixxieTypeInfo>(
212
+ config: NixxieConfig<TypeInfo>
213
+ ): NixxieConfig<TypeInfo> {
214
+ throwIfInvalidConfig(config)
215
+ let { ui } = config
216
+ if (!ui?.isDisabled) {
217
+ const {
218
+ getAdditionalFiles = () => [],
219
+ isAccessAllowed = defaultIsAccessAllowed,
220
+ pageMiddleware,
221
+ publicPages = [],
222
+ } = ui || {}
223
+ const authPublicPages = [`${ui?.basePath ?? ''}/signin`]
224
+ ui = {
225
+ ...ui,
226
+ publicPages: [...publicPages, ...authPublicPages],
227
+ getAdditionalFiles: async () => [
228
+ ...(await getAdditionalFiles()),
229
+ ...authGetAdditionalFiles(config),
230
+ ],
231
+
232
+ isAccessAllowed: async (context: NixxieContext) => {
233
+ if (await hasInitFirstItemConditions(context)) return true
234
+ return isAccessAllowed(context)
235
+ },
236
+
237
+ pageMiddleware: async args => {
238
+ const shouldRedirect = await authMiddleware(args)
239
+ if (shouldRedirect) return shouldRedirect
240
+ return pageMiddleware?.(args)
241
+ },
242
+ }
243
+ }
244
+
245
+ if (!config.session) throw new TypeError('Missing .session configuration')
246
+
247
+ const { graphql } = config
248
+ const { extendGraphqlSchema = defaultExtendGraphqlSchema } = graphql ?? {}
249
+ const listConfig = config.lists[listKey]
250
+
251
+ /**
252
+ * extendGraphqlSchema
253
+ *
254
+ * Must be added to the extendGraphqlSchema config. Can be composed.
255
+ */
256
+ const authGqlNames = getAuthGqlNames(listConfig.graphql?.singular ?? listKey)
257
+ const authExtendGraphqlSchema = getSchemaExtension({
258
+ authGqlNames,
259
+ listKey,
260
+ identityField,
261
+ secretField,
262
+ initFirstItem,
263
+ sessionData,
264
+ })
265
+
266
+ return {
267
+ ...config,
268
+ graphql: {
269
+ ...config.graphql,
270
+ extendGraphqlSchema: schema => {
271
+ return extendGraphqlSchema(authExtendGraphqlSchema(schema))
272
+ },
273
+ },
274
+ ui,
275
+ session: authSessionStrategy(config.session),
276
+ lists: {
277
+ ...config.lists,
278
+ [listKey]: {
279
+ ...listConfig,
280
+ fields: {
281
+ ...listConfig.fields,
282
+ },
283
+ },
284
+ },
285
+ }
286
+ }
287
+
288
+ return {
289
+ withAuth,
290
+ }
291
+ }
@@ -0,0 +1,23 @@
1
+ import { useMemo } from 'react'
2
+ import { useRouter } from '@nixxie-cms/core/admin-ui/router'
3
+
4
+ /**
5
+ * Returns a safe, site-relative path to redirect to after authentication.
6
+ *
7
+ * Reads the `from` query parameter and only honours it when it is a
8
+ * site-relative path (starts with a single `/`). Absolute URLs and
9
+ * protocol-relative URLs (`//evil.com`) are rejected to prevent open
10
+ * redirects; in those cases it falls back to `/`.
11
+ */
12
+ export function useRedirect() {
13
+ const router = useRouter()
14
+ const raw = router.query.from
15
+ const from = Array.isArray(raw) ? raw[0] : raw
16
+
17
+ return useMemo(() => {
18
+ if (typeof from === 'string' && from.startsWith('/') && !from.startsWith('//')) {
19
+ return from
20
+ }
21
+ return '/'
22
+ }, [from])
23
+ }