From c4fe46c678aad9c74ba6e3a4f7efb37dbacc7836 Mon Sep 17 00:00:00 2001 From: Daniel Cousens Date: Thu, 16 Jul 2026 08:00:15 +1000 Subject: [PATCH 1/3] add new session example --- examples/custom-session-api-key/README.md | 31 ++ .../api-key-field/index.ts | 14 + .../api-key-field/views.tsx | 30 ++ examples/custom-session-api-key/keystone.ts | 133 ++++++++ examples/custom-session-api-key/package.json | 24 ++ .../custom-session-api-key/schema.graphql | 318 ++++++++++++++++++ examples/custom-session-api-key/schema.prisma | 22 ++ examples/custom-session-api-key/schema.ts | 203 +++++++++++ pnpm-lock.yaml | 28 ++ 9 files changed, 803 insertions(+) create mode 100644 examples/custom-session-api-key/README.md create mode 100644 examples/custom-session-api-key/api-key-field/index.ts create mode 100644 examples/custom-session-api-key/api-key-field/views.tsx create mode 100644 examples/custom-session-api-key/keystone.ts create mode 100644 examples/custom-session-api-key/package.json create mode 100644 examples/custom-session-api-key/schema.graphql create mode 100644 examples/custom-session-api-key/schema.prisma create mode 100644 examples/custom-session-api-key/schema.ts diff --git a/examples/custom-session-api-key/README.md b/examples/custom-session-api-key/README.md new file mode 100644 index 00000000000..33dc8c85195 --- /dev/null +++ b/examples/custom-session-api-key/README.md @@ -0,0 +1,31 @@ +## Feature Example - API Key Session Strategy + +This project demonstrates how to compose session strategies. + +Users can sign in with the standard `@keystone-6/auth` password flow. API requests can also authenticate with example-specific API key headers: + +```shell +Keystone-Example-API-Key-ID: +Keystone-Example-API-Key-Secret: +``` + +The headers are branded for this example so they do not collide with other platform or proxy headers. + +The API key secret is stored in a Keystone `password` field, so Keystone hashes it before writing to the database. The `apiKeyExpiresAt` field controls expiry. + +## Instructions + +To run this project, clone the Keystone repository locally, run `pnpm install` at the root of the repository then navigate to this directory and run: + +```shell +pnpm dev +``` + +This will start the Admin UI at [localhost:3000](http://localhost:3000). + +Create a user, generate an API key secret, set an expiry, then make GraphQL requests with: + +```shell +Keystone-Example-API-Key-ID: +Keystone-Example-API-Key-Secret: +``` diff --git a/examples/custom-session-api-key/api-key-field/index.ts b/examples/custom-session-api-key/api-key-field/index.ts new file mode 100644 index 00000000000..68309bfceb6 --- /dev/null +++ b/examples/custom-session-api-key/api-key-field/index.ts @@ -0,0 +1,14 @@ +import { password as corePassword } from '@keystone-6/core/fields' +import type { BaseListTypeInfo, FieldTypeFunc } from '@keystone-6/core/types' +import type { PasswordFieldConfig } from '@keystone-6/core/fields/types/password' + +export function password( + config: PasswordFieldConfig = {} +): FieldTypeFunc { + const field = corePassword(config) + + return meta => ({ + ...field(meta), + views: './api-key-field/views', + }) +} diff --git a/examples/custom-session-api-key/api-key-field/views.tsx b/examples/custom-session-api-key/api-key-field/views.tsx new file mode 100644 index 00000000000..32e6269e3f1 --- /dev/null +++ b/examples/custom-session-api-key/api-key-field/views.tsx @@ -0,0 +1,30 @@ +import { Field as PasswordField, Cell, controller } from '@keystone-6/core/fields/types/password/views' +import type { FieldProps } from '@keystone-6/core/types' + +export { Cell, controller } + +function generateSecret() { + const bytes = new Uint8Array(32) + crypto.getRandomValues(bytes) + return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('') +} + +export function Field(props: FieldProps) { + const { onChange, value } = props + + const generateApiKeySecret = () => { + const secret = generateSecret() + onChange?.({ kind: 'editing', isSet: value.isSet, value: secret, confirm: secret }) + } + + return ( +
+ + {onChange && ( + + )} +
+ ) +} diff --git a/examples/custom-session-api-key/keystone.ts b/examples/custom-session-api-key/keystone.ts new file mode 100644 index 00000000000..fac72856fe5 --- /dev/null +++ b/examples/custom-session-api-key/keystone.ts @@ -0,0 +1,133 @@ +import { config } from '@keystone-6/core' +import { statelessSessions } from '@keystone-6/core/session' +import { createAuth } from '@keystone-6/auth' +import { getPasswordFieldKDF } from '@keystone-6/core/fields/types/password' +import type { SessionStrategy } from '@keystone-6/core/types' +import { type Session, lists } from './schema' +import type { Context, TypeInfo } from '.keystone/types' + +// WARNING: this example is for demonstration purposes only +// as with each of our examples, it has not been vetted +// or tested for any particular usage + +// WARNING: you need to change this +const sessionSecret = '-- DEV COOKIE SECRET; CHANGE ME --' + +// statelessSessions uses cookies for session tracking +// these cookies have an expiry, in seconds +// we use an expiry of one hour for this example +const sessionMaxAge = 60 * 60 + +// withAuth is a function we can use to wrap our base configuration +const { withAuth } = createAuth({ + // this is the list that contains our users + listKey: 'User', + + // an identity field, typically a username or an email address + identityField: 'name', + + // a secret field must be a password field type + secretField: 'password', + + // initFirstItem enables the "First User" experience, this will add an interface form + // adding a new User item if the database is empty + // + // WARNING: do not use initFirstItem in production + // see https://keystonejs.com/docs/config/auth#init-first-item for more + initFirstItem: { + // the following fields are used by the "Create First User" form + fields: ['name', 'password'], + + // the following fields are configured by default for this item + itemData: { + // isAdmin is true, so the admin can pass isAccessAllowed (see below) + isAdmin: true, + }, + }, + + // add isAdmin to the session data + sessionData: 'isAdmin', +}) + +const cookieSessionStrategy = statelessSessions({ + // the maxAge option controls how long session cookies are valid for before they expire + maxAge: sessionMaxAge, + // the session secret is used to encrypt cookie data + secret: sessionSecret, +}) + +function getHeaderValue(header: string | string[] | undefined) { + if (Array.isArray(header)) return + return header +} + +function getApiKeyCredentials(headers: Context['req']['headers']) { + const itemId = getHeaderValue(headers['keystone-example-api-key-id']) + const secret = getHeaderValue(headers['keystone-example-api-key-secret']) + if (!itemId) return + if (!secret) return + + return { itemId, secret } +} + +const apiKeySessionStrategy = { + async get({ context }: { context: Context }): Promise { + if (!context.req) return + + const credentials = getApiKeyCredentials(context.req.headers) + if (!credentials) return + + const user = await context.sudo().prisma.user.findUnique({ + where: { id: credentials.itemId }, + select: { id: true, apiKey: true, apiKeyExpiresAt: true, isAdmin: true }, + }) + if (!user?.apiKey) return + if (!user.apiKeyExpiresAt) return + if (new Date(user.apiKeyExpiresAt) <= new Date()) return + + const kdf = getPasswordFieldKDF(context.graphql.schema, 'User', 'apiKey') + if (!kdf) return + if (!(await kdf.compare(credentials.secret, user.apiKey))) return + + return { + itemId: user.id, + data: { + isAdmin: user.isAdmin, + }, + } + }, + async start() {}, + async end() {}, +} satisfies SessionStrategy + +const composedSessionStrategy = { + async get({ context }: { context: Context }) { + const apiKeySession = await apiKeySessionStrategy.get({ context }) + if (apiKeySession) return apiKeySession + + return cookieSessionStrategy.get({ context }) + }, + start: cookieSessionStrategy.start, + end: cookieSessionStrategy.end, +} satisfies SessionStrategy + +export default withAuth>( + config({ + db: { + provider: 'sqlite', + url: process.env.DATABASE_URL ?? 'file:./keystone-example.db', + + // WARNING: this is only needed for our monorepo examples, dont do this + prismaClientPath: 'node_modules/myprisma', + }, + lists, + ui: { + // only admins can view the AdminUI + isAccessAllowed: context => { + return context.session?.data?.isAdmin ?? false + }, + }, + // you can find out more at https://keystonejs.com/docs/apis/session#session-api + session: composedSessionStrategy, + }) +) diff --git a/examples/custom-session-api-key/package.json b/examples/custom-session-api-key/package.json new file mode 100644 index 00000000000..bc557f134ba --- /dev/null +++ b/examples/custom-session-api-key/package.json @@ -0,0 +1,24 @@ +{ + "name": "@keystone-6/example-custom-session-api-key", + "version": null, + "private": true, + "license": "MIT", + "scripts": { + "dev": "keystone dev", + "start": "keystone start", + "build": "keystone build", + "check": "keystone postinstall" + }, + "dependencies": { + "@keystone-6/auth": "workspace:^", + "@keystone-6/core": "workspace:^", + "@prisma/client": "catalog:", + "next": "catalog:", + "react": "catalog:", + "react-dom": "catalog:" + }, + "devDependencies": { + "prisma": "catalog:", + "typescript": "catalog:" + } +} diff --git a/examples/custom-session-api-key/schema.graphql b/examples/custom-session-api-key/schema.graphql new file mode 100644 index 00000000000..bf3fe6f889e --- /dev/null +++ b/examples/custom-session-api-key/schema.graphql @@ -0,0 +1,318 @@ +# This file is automatically generated by Keystone, do not modify it manually. +# Modify your Keystone config when you want to change this. + +type User { + id: ID! + name: String + password: PasswordState + apiKey: PasswordState + apiKeyExpiresAt: DateTime + isAdmin: Boolean +} + +type PasswordState { + isSet: Boolean! +} + +scalar DateTime @specifiedBy(url: "https://datatracker.ietf.org/doc/html/rfc3339#section-5.6") + +input UserWhereUniqueInput { + id: ID +} + +input UserWhereInput { + AND: [UserWhereInput!] + OR: [UserWhereInput!] + NOT: [UserWhereInput!] + id: IDFilter + apiKey: PasswordFilter + apiKeyExpiresAt: DateTimeNullableFilter + isAdmin: BooleanFilter +} + +input IDFilter { + equals: ID + in: [ID!] + notIn: [ID!] + lt: ID + lte: ID + gt: ID + gte: ID + not: IDFilter +} + +input PasswordFilter { + isSet: Boolean! +} + +input DateTimeNullableFilter { + equals: DateTime + in: [DateTime!] + notIn: [DateTime!] + lt: DateTime + lte: DateTime + gt: DateTime + gte: DateTime + not: DateTimeNullableFilter +} + +input BooleanFilter { + equals: Boolean + not: BooleanFilter +} + +input UserOrderByInput { + id: OrderDirection + apiKeyExpiresAt: OrderDirection + isAdmin: OrderDirection +} + +enum OrderDirection { + asc + desc +} + +input UserUpdateInput { + name: String + password: String + apiKey: String + apiKeyExpiresAt: DateTime + isAdmin: Boolean +} + +input UserUpdateArgs { + where: UserWhereUniqueInput! + data: UserUpdateInput! +} + +input UserCreateInput { + name: String + password: String + apiKey: String + apiKeyExpiresAt: DateTime + isAdmin: Boolean +} + +""" +The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). +""" +scalar JSON @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf") + +type Mutation { + createUser(data: UserCreateInput!): User + createUsers(data: [UserCreateInput!]!): [User] + updateUser(where: UserWhereUniqueInput!, data: UserUpdateInput!): User + updateUsers(data: [UserUpdateArgs!]!): [User] + deleteUser(where: UserWhereUniqueInput!): User + deleteUsers(where: [UserWhereUniqueInput!]!): [User] + endSession: Boolean! + authenticateUserWithPassword(name: String!, password: String!): UserAuthenticationWithPasswordResult + createInitialUser(data: CreateInitialUserInput!): UserAuthenticationWithPasswordSuccess! +} + +union UserAuthenticationWithPasswordResult = UserAuthenticationWithPasswordSuccess | UserAuthenticationWithPasswordFailure + +type UserAuthenticationWithPasswordSuccess { + sessionToken: String! + item: User! +} + +type UserAuthenticationWithPasswordFailure { + message: String! +} + +input CreateInitialUserInput { + name: String + password: String +} + +type Query { + user(where: UserWhereUniqueInput!): User + users(where: UserWhereInput! = {}, orderBy: [UserOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: UserWhereUniqueInput): [User!] + usersCount(where: UserWhereInput! = {}): Int + keystone: KeystoneMeta! + authenticatedItem: User +} + +type KeystoneMeta { + adminMeta: KeystoneAdminMeta! +} + +type KeystoneAdminMeta { + lists: [KeystoneAdminUIListMeta!]! + list(key: String!, itemId: ID): KeystoneAdminUIListMeta +} + +type KeystoneAdminUIListMeta { + key: String! + label: String! + singular: String! + plural: String! + path: String! + labelField: String! + fields: [KeystoneAdminUIFieldMeta!]! + groups: [KeystoneAdminUIFieldGroupMeta!]! + actions: [KeystoneAdminUIActionMeta!]! + graphql: KeystoneAdminUIGraphQL! + pageSize: Int! + initialColumns: [String!]! + initialSearchFields: [String!]! + initialSort: KeystoneAdminUISort + initialFilter: JSON + hiddenFilter: JSON + isSingleton: Boolean! + hideNavigation: Boolean! + hideCreate: Boolean! + hideDelete: Boolean! +} + +type KeystoneAdminUIFieldMeta { + key: String! + label: String! + description: String + isOrderable: Boolean! + isFilterable: Boolean! + isNonNull: [KeystoneAdminUIFieldMetaIsNonNull!] + fieldMeta: JSON + viewsIndex: Int! + customViewsIndex: Int + createView: KeystoneAdminUIFieldMetaCreateView! + itemView: KeystoneAdminUIFieldMetaItemView + listView: KeystoneAdminUIFieldMetaListView! + search: QueryMode +} + +enum KeystoneAdminUIFieldMetaIsNonNull { + read + create + update +} + +type KeystoneAdminUIFieldMetaCreateView { + fieldMode: JSON! + isRequired: JSON! +} + +type KeystoneAdminUIFieldMetaItemView { + fieldMode: JSON! + fieldPosition: KeystoneAdminUIFieldMetaItemViewFieldPosition! + isRequired: JSON! +} + +enum KeystoneAdminUIFieldMetaItemViewFieldPosition { + form + sidebar +} + +type KeystoneAdminUIFieldMetaListView { + fieldMode: KeystoneAdminUIFieldMetaListViewFieldMode! +} + +enum KeystoneAdminUIFieldMetaListViewFieldMode { + read + hidden +} + +enum QueryMode { + default + insensitive +} + +type KeystoneAdminUIFieldGroupMeta { + label: String! + description: String + fields: [KeystoneAdminUIFieldMeta!]! +} + +type KeystoneAdminUIActionMeta { + key: String! + label: String! + icon: String + messages: KeystoneAdminUIActionMetaMessages! + graphql: KeystoneAdminUIActionMetaGraphQL + itemView: KeystoneAdminUIActionMetaItemView + listView: KeystoneAdminUIActionMetaListView! +} + +type KeystoneAdminUIActionMetaMessages { + promptTitle: String! + promptTitleMany: String + prompt: String! + promptMany: String + promptConfirmLabel: String! + promptConfirmLabelMany: String + fail: String! + failMany: String + success: String! + successMany: String +} + +type KeystoneAdminUIActionMetaGraphQL { + arguments: [KeystoneAdminUIActionMetaGraphQLArgument!]! + names: KeystoneAdminUIActionMetaGraphQLNames! +} + +type KeystoneAdminUIActionMetaGraphQLArgument { + name: String! + type: String! + source: JSON +} + +type KeystoneAdminUIActionMetaGraphQLNames { + one: String! + many: String +} + +type KeystoneAdminUIActionMetaItemView { + actionMode: JSON! + navigation: KeystoneAdminUIActionMetaItemViewNavigation! + hidePrompt: Boolean! + hideToast: Boolean! +} + +enum KeystoneAdminUIActionMetaItemViewNavigation { + follow + refetch + return +} + +type KeystoneAdminUIActionMetaListView { + actionMode: JSON! +} + +type KeystoneAdminUIGraphQL { + names: KeystoneAdminUIGraphQLNames! +} + +type KeystoneAdminUIGraphQLNames { + outputTypeName: String! + whereInputName: String! + whereUniqueInputName: String! + createInputName: String! + createMutationName: String! + createManyMutationName: String! + relateToOneForCreateInputName: String! + relateToManyForCreateInputName: String! + itemQueryName: String! + listOrderName: String! + listQueryCountName: String! + listQueryName: String! + updateInputName: String! + updateMutationName: String! + updateManyInputName: String! + updateManyMutationName: String! + relateToOneForUpdateInputName: String! + relateToManyForUpdateInputName: String! + deleteMutationName: String! + deleteManyMutationName: String! +} + +type KeystoneAdminUISort { + field: String! + direction: KeystoneAdminUISortDirection! +} + +enum KeystoneAdminUISortDirection { + ASC + DESC +} diff --git a/examples/custom-session-api-key/schema.prisma b/examples/custom-session-api-key/schema.prisma new file mode 100644 index 00000000000..00fc6c720bc --- /dev/null +++ b/examples/custom-session-api-key/schema.prisma @@ -0,0 +1,22 @@ +// This file is automatically generated by Keystone, do not modify it manually. +// Modify your Keystone config when you want to change this. + +datasource sqlite { + url = env("DATABASE_URL") + shadowDatabaseUrl = env("SHADOW_DATABASE_URL") + provider = "sqlite" +} + +generator client { + provider = "prisma-client-js" + output = "node_modules/myprisma" +} + +model User { + id String @id @default(cuid()) + name String @unique @default("") + password String + apiKey String? + apiKeyExpiresAt DateTime? + isAdmin Boolean @default(false) +} diff --git a/examples/custom-session-api-key/schema.ts b/examples/custom-session-api-key/schema.ts new file mode 100644 index 00000000000..cebbc0c0bc5 --- /dev/null +++ b/examples/custom-session-api-key/schema.ts @@ -0,0 +1,203 @@ +import { list } from '@keystone-6/core' +import { allowAll, denyAll } from '@keystone-6/core/access' +import { text, checkbox, timestamp } from '@keystone-6/core/fields' +import { password } from './api-key-field' +import type { Lists } from '.keystone/types' + +// WARNING: this example is for demonstration purposes only +// as with each of our examples, it has not been vetted +// or tested for any particular usage + +export type Session = { + itemId: string + data: { + isAdmin: boolean + } +} + +function hasSession({ session }: { session?: Session }) { + return Boolean(session) +} + +function isAdminOrSameUser({ session, item }: { session?: Session; item: Lists.User.Item | null }) { + // you need to have a session to do this + if (!session) return false + + // admins can do anything + if (session.data.isAdmin) return true + + // no item? then no + if (!item) return false + + // the authenticated user needs to be equal to the user we are updating + return session.itemId === item.id +} + +function isAdminOrSameUserFilter({ session }: { session?: Session }) { + // you need to have a session to do this + if (!session) return false + + // admins can see everything + if (session.data?.isAdmin) return {} + + // only yourself + return { + id: { + equals: session.itemId, + }, + } +} + +function isAdmin({ session }: { session?: Session }) { + // you need to have a session to do this + if (!session) return false + + // admins can do anything + if (session.data.isAdmin) return true + + // otherwise, no + return false +} + +function isAdminOrSameUserCreate({ session, inputData }: { session?: Session; inputData: any }) { + if (!session) return false + if (session.data.isAdmin) return true + return inputData.id === session.itemId +} + +export const lists = { + User: list({ + access: { + operation: { + create: allowAll, + query: allowAll, + + // what a user can update is limited by + // the access.filter.* and access.item.* access controls + update: hasSession, + + // only admins can delete users + delete: isAdmin, + }, + filter: { + update: isAdminOrSameUserFilter, + }, + item: { + // this is redundant as ^filter.update should stop unauthorised updates + // we include it anyway as a demonstration + update: isAdminOrSameUser, + }, + }, + ui: { + // only show deletion options for admins + hideDelete: args => !isAdmin(args), + listView: { + // the default columns that will be displayed in the list view + initialColumns: ['name', 'isAdmin'], + }, + }, + fields: { + // the user's name, used as the identity field for authentication + // should not be publicly visible + // + // we use isIndexed to enforce names are unique + // that may not be suitable for your application + name: text({ + access: { + // only the respective user, or an admin can read this field + read: isAdminOrSameUser, + + // only admins can update this field + update: isAdmin, + }, + isFilterable: false, + isOrderable: false, + isIndexed: 'unique', + validation: { + isRequired: true, + }, + }), + + // the user's password, used as the secret field for authentication + // should not be publicly visible + password: password({ + access: { + read: denyAll, // TODO: is this required? + update: isAdminOrSameUser, + }, + validation: { + isRequired: true, + }, + ui: { + itemView: { + // don't show this field if it isn't relevant + fieldMode: args => (isAdminOrSameUser(args) ? 'edit' : 'hidden'), + }, + listView: { + fieldMode: 'hidden', // TODO: is this required? + }, + }, + }), + + // the API key secret is hashed by the password field before it is stored + apiKey: password({ + access: { + read: isAdminOrSameUser, + create: isAdminOrSameUserCreate, + update: isAdminOrSameUser, + }, + validation: { + length: { + min: 32, + }, + }, + ui: { + description: + 'Use as the secret in the Keystone-Example-API-Key-Secret header, with the user id in Keystone-Example-API-Key-ID.', + itemView: { + fieldMode: args => (isAdminOrSameUser(args) ? 'edit' : 'hidden'), + }, + listView: { + fieldMode: 'hidden', + }, + }, + }), + + apiKeyExpiresAt: timestamp({ + access: { + read: isAdminOrSameUser, + create: isAdminOrSameUserCreate, + update: isAdminOrSameUser, + }, + ui: { + itemView: { + fieldMode: args => (isAdminOrSameUser(args) ? 'edit' : 'hidden'), + }, + }, + }), + + // a flag to indicate if this user is an admin + // should not be publicly visible + isAdmin: checkbox({ + access: { + // only the respective user, or an admin can read this field + read: isAdminOrSameUser, + + // only admins can create, or update this field + create: isAdmin, + update: isAdmin, + }, + defaultValue: false, + ui: { + // only admins can edit this field + createView: { + fieldMode: args => (isAdmin(args) ? 'edit' : 'hidden'), + }, + itemView: { + fieldMode: args => (isAdmin(args) ? 'edit' : 'read'), + }, + }, + }), + }, + }), +} satisfies Lists diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83f4ef1540a..5539d72913c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -668,6 +668,34 @@ importers: specifier: 'catalog:' version: 6.0.3 + examples/custom-session-api-key: + dependencies: + '@keystone-6/auth': + specifier: workspace:^ + version: link:../../packages/auth + '@keystone-6/core': + specifier: workspace:^ + version: link:../../packages/core + '@prisma/client': + specifier: 'catalog:' + version: 6.19.3(prisma@6.19.3(magicast@0.3.5)(typescript@6.0.3))(typescript@6.0.3) + next: + specifier: 'catalog:' + version: 16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(babel-plugin-macros@3.1.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: + specifier: 'catalog:' + version: 19.2.5 + react-dom: + specifier: 'catalog:' + version: 19.2.5(react@19.2.5) + devDependencies: + prisma: + specifier: 'catalog:' + version: 6.19.3(magicast@0.3.5)(typescript@6.0.3) + typescript: + specifier: 'catalog:' + version: 6.0.3 + examples/custom-session-invalidation: dependencies: '@keystone-6/auth': From 89a9a66b751f41111a618d283951ed594ee89c75 Mon Sep 17 00:00:00 2001 From: Daniel Cousens Date: Thu, 16 Jul 2026 11:01:31 +1000 Subject: [PATCH 2/3] update access/graphql controls --- examples/custom-session-api-key/schema.ts | 31 +++++++++++++++++++---- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/examples/custom-session-api-key/schema.ts b/examples/custom-session-api-key/schema.ts index cebbc0c0bc5..62a5fc6b9fb 100644 --- a/examples/custom-session-api-key/schema.ts +++ b/examples/custom-session-api-key/schema.ts @@ -70,7 +70,7 @@ export const lists = { access: { operation: { create: allowAll, - query: allowAll, + query: hasSession, // what a user can update is limited by // the access.filter.* and access.item.* access controls @@ -80,6 +80,7 @@ export const lists = { delete: isAdmin, }, filter: { + query: isAdminOrSameUserFilter, update: isAdminOrSameUserFilter, }, item: { @@ -122,7 +123,7 @@ export const lists = { // should not be publicly visible password: password({ access: { - read: denyAll, // TODO: is this required? + read: denyAll, update: isAdminOrSameUser, }, validation: { @@ -134,7 +135,7 @@ export const lists = { fieldMode: args => (isAdminOrSameUser(args) ? 'edit' : 'hidden'), }, listView: { - fieldMode: 'hidden', // TODO: is this required? + fieldMode: 'hidden', }, }, }), @@ -146,6 +147,12 @@ export const lists = { create: isAdminOrSameUserCreate, update: isAdminOrSameUser, }, + graphql: { + omit: { + create: true, + }, + }, + isFilterable: false, validation: { length: { min: 32, @@ -166,9 +173,16 @@ export const lists = { apiKeyExpiresAt: timestamp({ access: { read: isAdminOrSameUser, - create: isAdminOrSameUserCreate, - update: isAdminOrSameUser, + create: isAdmin, + update: isAdmin, }, + graphql: { + omit: { + create: true, + }, + }, + isFilterable: false, + isOrderable: false, ui: { itemView: { fieldMode: args => (isAdminOrSameUser(args) ? 'edit' : 'hidden'), @@ -188,6 +202,13 @@ export const lists = { update: isAdmin, }, defaultValue: false, + graphql: { + omit: { + create: true, + }, + }, + isFilterable: false, + isOrderable: false, ui: { // only admins can edit this field createView: { From d43c6f9f9b9676897fb7754a1944638dc69330f2 Mon Sep 17 00:00:00 2001 From: Daniel Cousens Date: Thu, 16 Jul 2026 11:16:38 +1000 Subject: [PATCH 3/3] update field views --- .../api-key-field/index.ts | 6 + .../api-key-field/views.tsx | 110 ++++++++++++++++-- examples/custom-session-api-key/package.json | 1 + .../custom-session-api-key/schema.graphql | 28 ----- examples/custom-session-api-key/schema.ts | 110 +++++++++--------- 5 files changed, 163 insertions(+), 92 deletions(-) diff --git a/examples/custom-session-api-key/api-key-field/index.ts b/examples/custom-session-api-key/api-key-field/index.ts index 68309bfceb6..c2a7f0d2fe2 100644 --- a/examples/custom-session-api-key/api-key-field/index.ts +++ b/examples/custom-session-api-key/api-key-field/index.ts @@ -4,6 +4,12 @@ import type { PasswordFieldConfig } from '@keystone-6/core/fields/types/password export function password( config: PasswordFieldConfig = {} +): FieldTypeFunc { + return corePassword(config) +} + +export function apiKey( + config: PasswordFieldConfig = {} ): FieldTypeFunc { const field = corePassword(config) diff --git a/examples/custom-session-api-key/api-key-field/views.tsx b/examples/custom-session-api-key/api-key-field/views.tsx index 32e6269e3f1..286e8164e95 100644 --- a/examples/custom-session-api-key/api-key-field/views.tsx +++ b/examples/custom-session-api-key/api-key-field/views.tsx @@ -1,8 +1,48 @@ -import { Field as PasswordField, Cell, controller } from '@keystone-6/core/fields/types/password/views' +import { useId, useState } from 'react' +import { Cell, controller } from '@keystone-6/core/fields/types/password/views' +import { ActionButton, ToggleButton } from '@keystar/ui/button' +import { FieldLabel, FieldMessage } from '@keystar/ui/field' +import { Icon } from '@keystar/ui/icon' +import { eyeIcon } from '@keystar/ui/icon/icons/eyeIcon' +import { rotateCwIcon } from '@keystar/ui/icon/icons/rotateCwIcon' +import { Flex, VStack } from '@keystar/ui/layout' +import { css, tokenSchema } from '@keystar/ui/style' +import { TextField } from '@keystar/ui/text-field' +import { Tooltip, TooltipTrigger } from '@keystar/ui/tooltip' +import { Text } from '@keystar/ui/typography' import type { FieldProps } from '@keystone-6/core/types' export { Cell, controller } +function InlineCode(props: { children: string }) { + return ( + + {props.children} + + ) +} + +function validate(value: FieldProps['value'], field: ReturnType) { + if (value.kind === 'initial') return + if (value.value.length < field.validation.length.min) { + return `${field.label} must be at least ${field.validation.length.min} characters long` + } + if (field.validation.length.max !== null && value.value.length > field.validation.length.max) { + return `${field.label} must be no longer than ${field.validation.length.max} characters` + } + if (field.validation.match && !field.validation.match.regex.test(value.value)) { + return field.validation.match.explanation + } +} + function generateSecret() { const bytes = new Uint8Array(32) crypto.getRandomValues(bytes) @@ -10,21 +50,73 @@ function generateSecret() { } export function Field(props: FieldProps) { - const { onChange, value } = props + const { autoFocus, field, forceValidation, onChange, value } = props + const [secureTextEntry, setSecureTextEntry] = useState(true) + const [touched, setTouched] = useState(false) + const labelId = useId() + const itemId = typeof props.itemValue.id === 'string' ? props.itemValue.id : '' + + const editingValue = value.kind === 'editing' ? value.value : '' + const displayValue = secureTextEntry ? editingValue.replace(/./g, '•') : editingValue + const secretPreview = editingValue ? editingValue.replace(/./g, '*') : '' + const validationMessage = forceValidation || touched ? validate(value, field) : undefined const generateApiKeySecret = () => { const secret = generateSecret() + setSecureTextEntry(false) onChange?.({ kind: 'editing', isSet: value.isSet, value: secret, confirm: secret }) } return ( -
- - {onChange && ( - + + + {field.label} + + + + Send Keystone-Example-API-Key-ID: {itemId} + + + Send Keystone-Example-API-Key-Secret: {secretPreview} + + + + setTouched(true)} + placeholder="API key secret" + value={displayValue} + flex + /> + {!!editingValue && ( + + setSecureTextEntry(bool => !bool)} + > + + + Show API key secret + + )} + {onChange && ( + + + + + Generate API key secret + + )} + + {!!editingValue && ( + + Copy this API key secret now. It cannot be accessed again after saving. + )} -
+ {!!validationMessage && {validationMessage}} + ) } diff --git a/examples/custom-session-api-key/package.json b/examples/custom-session-api-key/package.json index bc557f134ba..ff19572cb14 100644 --- a/examples/custom-session-api-key/package.json +++ b/examples/custom-session-api-key/package.json @@ -12,6 +12,7 @@ "dependencies": { "@keystone-6/auth": "workspace:^", "@keystone-6/core": "workspace:^", + "@keystar/ui": "^0.7.22", "@prisma/client": "catalog:", "next": "catalog:", "react": "catalog:", diff --git a/examples/custom-session-api-key/schema.graphql b/examples/custom-session-api-key/schema.graphql index bf3fe6f889e..8dba4fc11c3 100644 --- a/examples/custom-session-api-key/schema.graphql +++ b/examples/custom-session-api-key/schema.graphql @@ -25,9 +25,6 @@ input UserWhereInput { OR: [UserWhereInput!] NOT: [UserWhereInput!] id: IDFilter - apiKey: PasswordFilter - apiKeyExpiresAt: DateTimeNullableFilter - isAdmin: BooleanFilter } input IDFilter { @@ -41,30 +38,8 @@ input IDFilter { not: IDFilter } -input PasswordFilter { - isSet: Boolean! -} - -input DateTimeNullableFilter { - equals: DateTime - in: [DateTime!] - notIn: [DateTime!] - lt: DateTime - lte: DateTime - gt: DateTime - gte: DateTime - not: DateTimeNullableFilter -} - -input BooleanFilter { - equals: Boolean - not: BooleanFilter -} - input UserOrderByInput { id: OrderDirection - apiKeyExpiresAt: OrderDirection - isAdmin: OrderDirection } enum OrderDirection { @@ -88,9 +63,6 @@ input UserUpdateArgs { input UserCreateInput { name: String password: String - apiKey: String - apiKeyExpiresAt: DateTime - isAdmin: Boolean } """ diff --git a/examples/custom-session-api-key/schema.ts b/examples/custom-session-api-key/schema.ts index 62a5fc6b9fb..b2416a4a23d 100644 --- a/examples/custom-session-api-key/schema.ts +++ b/examples/custom-session-api-key/schema.ts @@ -1,7 +1,7 @@ -import { list } from '@keystone-6/core' +import { group, list } from '@keystone-6/core' import { allowAll, denyAll } from '@keystone-6/core/access' import { text, checkbox, timestamp } from '@keystone-6/core/fields' -import { password } from './api-key-field' +import { apiKey, password } from './api-key-field' import type { Lists } from '.keystone/types' // WARNING: this example is for demonstration purposes only @@ -59,12 +59,6 @@ function isAdmin({ session }: { session?: Session }) { return false } -function isAdminOrSameUserCreate({ session, inputData }: { session?: Session; inputData: any }) { - if (!session) return false - if (session.data.isAdmin) return true - return inputData.id === session.itemId -} - export const lists = { User: list({ access: { @@ -135,58 +129,64 @@ export const lists = { fieldMode: args => (isAdminOrSameUser(args) ? 'edit' : 'hidden'), }, listView: { + // UI-only. Field access above is the security boundary. fieldMode: 'hidden', }, }, }), - // the API key secret is hashed by the password field before it is stored - apiKey: password({ - access: { - read: isAdminOrSameUser, - create: isAdminOrSameUserCreate, - update: isAdminOrSameUser, - }, - graphql: { - omit: { - create: true, - }, - }, - isFilterable: false, - validation: { - length: { - min: 32, - }, - }, - ui: { - description: - 'Use as the secret in the Keystone-Example-API-Key-Secret header, with the user id in Keystone-Example-API-Key-ID.', - itemView: { - fieldMode: args => (isAdminOrSameUser(args) ? 'edit' : 'hidden'), - }, - listView: { - fieldMode: 'hidden', - }, - }, - }), - - apiKeyExpiresAt: timestamp({ - access: { - read: isAdminOrSameUser, - create: isAdmin, - update: isAdmin, - }, - graphql: { - omit: { - create: true, - }, - }, - isFilterable: false, - isOrderable: false, - ui: { - itemView: { - fieldMode: args => (isAdminOrSameUser(args) ? 'edit' : 'hidden'), - }, + ...group({ + label: 'API key', + fields: { + // the API key secret is hashed by the password field before it is stored + apiKey: apiKey({ + label: 'API key secret', + access: { + read: isAdminOrSameUser, + create: isAdmin, + update: isAdminOrSameUser, + }, + graphql: { + omit: { + create: true, + }, + }, + isFilterable: false, + validation: { + length: { + min: 32, + }, + }, + ui: { + itemView: { + fieldMode: args => (isAdminOrSameUser(args) ? 'edit' : 'hidden'), + }, + listView: { + fieldMode: 'hidden', + }, + }, + }), + + apiKeyExpiresAt: timestamp({ + label: 'API key expires at', + access: { + read: isAdminOrSameUser, + create: isAdmin, + update: isAdmin, + }, + graphql: { + omit: { + create: true, + }, + }, + isFilterable: false, + isOrderable: false, + ui: { + itemView: { + fieldMode: args => (isAdminOrSameUser(args) ? 'edit' : 'hidden'), + }, + }, + }), }, }),