diff --git a/.changeset/add-cookie-name.md b/.changeset/add-cookie-name.md new file mode 100644 index 00000000000..a3ab2e89683 --- /dev/null +++ b/.changeset/add-cookie-name.md @@ -0,0 +1,5 @@ +--- +'@keystone-6/core': minor +--- + +Adds `cookieName` as an option for `statelessSessions` diff --git a/.changeset/more-session-types.md b/.changeset/more-session-types.md new file mode 100644 index 00000000000..d5c0406e8f2 --- /dev/null +++ b/.changeset/more-session-types.md @@ -0,0 +1,5 @@ +--- +'@keystone-6/core': minor +--- + +Adds `Session` type parameter to generated `TypeInfo`, `Lists` and `Context` types, and propagates that type to access control and hooks diff --git a/examples/auth/keystone.ts b/examples/auth/keystone.ts index 1d120f70a97..febcac36353 100644 --- a/examples/auth/keystone.ts +++ b/examples/auth/keystone.ts @@ -18,13 +18,13 @@ const sessionMaxAge = 60 * 60 * 24 * 30; // withAuth is a function we can use to wrap our base configuration const { withAuth } = createAuth({ - // this is the list that contains items people can sign in as + // this is the list that contains our users listKey: 'User', - // an identity field is typically a username or email address - identityField: 'email', + // an identity field, typically a username or an email address + identityField: 'name', - // a secret field must be a password type field + // a secret field must be a password field type secretField: 'password', // initFirstItem enables the "First User" experience, this will add an interface form @@ -34,7 +34,7 @@ const { withAuth } = createAuth({ // 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', 'email', 'password'], + fields: ['name', 'password'], // the following fields are configured by default for this item itemData: { @@ -43,18 +43,10 @@ const { withAuth } = createAuth({ }, }, - // add isAdmin to the session data(required by isAccessAllowed) + // add isAdmin to the session data sessionData: 'isAdmin', }); -// you can find out more at https://keystonejs.com/docs/apis/session#session-api -const session = statelessSessions({ - // an maxAge option controls how long session cookies are valid for before they expire - maxAge: sessionMaxAge, - // an session secret is used to encrypt cookie data (should be an environment variable) - secret: sessionSecret, -}); - export default withAuth( config({ db: { @@ -65,12 +57,18 @@ export default withAuth( ...fixPrismaPath, }, lists, - session, ui: { // only admins can view the AdminUI isAccessAllowed: ({ session }) => { - return session?.data?.isAdmin; + return session?.data?.isAdmin ?? false; }, }, + // you can find out more at https://keystonejs.com/docs/apis/session#session-api + session: 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, + }), }) ); diff --git a/examples/auth/schema.graphql b/examples/auth/schema.graphql index c455019e5a5..3bbe083ed5c 100644 --- a/examples/auth/schema.graphql +++ b/examples/auth/schema.graphql @@ -4,7 +4,6 @@ type User { id: ID! name: String - email: String password: PasswordState isAdmin: Boolean } @@ -22,8 +21,6 @@ input UserWhereInput { OR: [UserWhereInput!] NOT: [UserWhereInput!] id: IDFilter - name: StringFilter - password: PasswordFilter isAdmin: BooleanFilter } @@ -38,38 +35,6 @@ input IDFilter { not: IDFilter } -input StringFilter { - equals: String - in: [String!] - notIn: [String!] - lt: String - lte: String - gt: String - gte: String - contains: String - startsWith: String - endsWith: String - not: NestedStringFilter -} - -input NestedStringFilter { - equals: String - in: [String!] - notIn: [String!] - lt: String - lte: String - gt: String - gte: String - contains: String - startsWith: String - endsWith: String - not: NestedStringFilter -} - -input PasswordFilter { - isSet: Boolean! -} - input BooleanFilter { equals: Boolean not: BooleanFilter @@ -77,7 +42,6 @@ input BooleanFilter { input UserOrderByInput { id: OrderDirection - name: OrderDirection isAdmin: OrderDirection } @@ -88,7 +52,6 @@ enum OrderDirection { input UserUpdateInput { name: String - email: String password: String isAdmin: Boolean } @@ -100,7 +63,6 @@ input UserUpdateArgs { input UserCreateInput { name: String - email: String password: String isAdmin: Boolean } @@ -118,7 +80,7 @@ type Mutation { deleteUser(where: UserWhereUniqueInput!): User deleteUsers(where: [UserWhereUniqueInput!]!): [User] endSession: Boolean! - authenticateUserWithPassword(email: String!, password: String!): UserAuthenticationWithPasswordResult + authenticateUserWithPassword(name: String!, password: String!): UserAuthenticationWithPasswordResult createInitialUser(data: CreateInitialUserInput!): UserAuthenticationWithPasswordSuccess! } @@ -135,7 +97,6 @@ type UserAuthenticationWithPasswordFailure { input CreateInitialUserInput { name: String - email: String password: String } diff --git a/examples/auth/schema.prisma b/examples/auth/schema.prisma index 3d063241e74..9ae26a3cb1c 100644 --- a/examples/auth/schema.prisma +++ b/examples/auth/schema.prisma @@ -14,8 +14,7 @@ generator client { model User { id String @id @default(cuid()) - name String @default("") - email String @unique @default("") - password String? + name String @unique @default("") + password String isAdmin Boolean @default(false) } diff --git a/examples/auth/schema.ts b/examples/auth/schema.ts index b8423ce49d8..664c4c50545 100644 --- a/examples/auth/schema.ts +++ b/examples/auth/schema.ts @@ -1,5 +1,5 @@ import { list } from '@keystone-6/core'; -import { allowAll } from '@keystone-6/core/access'; +import { allowAll, denyAll } from '@keystone-6/core/access'; import { text, checkbox, password } from '@keystone-6/core/fields'; import type { Lists } from '.keystone/types'; @@ -14,17 +14,11 @@ type Session = { }; }; -function hasSession({ session }: { session: Session | undefined }) { +function hasSession({ session }: { session?: Session }) { return Boolean(session); } -function isAdminOrSameUser({ - session, - item, -}: { - session: Session | undefined; - item: Lists.User.Item; -}) { +function isAdminOrSameUser({ session, item }: { session?: Session; item: Lists.User.Item }) { // you need to have a session to do this if (!session) return false; @@ -35,7 +29,7 @@ function isAdminOrSameUser({ return session.itemId === item.id; } -function isAdminOrSameUserFilter({ session }: { session: Session | undefined }) { +function isAdminOrSameUserFilter({ session }: { session?: Session }) { // you need to have a session to do this if (!session) return false; @@ -50,7 +44,7 @@ function isAdminOrSameUserFilter({ session }: { session: Session | undefined }) }; } -function isAdmin({ session }: { session: Session | undefined }) { +function isAdmin({ session }: { session?: Session }) { // you need to have a session to do this if (!session) return false; @@ -89,18 +83,16 @@ export const lists: Lists = { hideDelete: args => !isAdmin(args), listView: { // the default columns that will be displayed in the list view - initialColumns: ['name', 'email', 'isAdmin'], + initialColumns: ['name', 'isAdmin'], }, }, fields: { - // the user's name, publicly visible - name: text({ validation: { isRequired: true } }), - - // the user's email address, used as the identity field for authentication + // the user's name, used as the identity field for authentication // should not be publicly visible // - // we use isIndexed to enforce this email is unique - email: text({ + // we use isIndexed to enforce names are unique + // that may not suitable for your application + name: text({ access: { // only the respective user, or an admin can read this field read: isAdminOrSameUser, @@ -120,17 +112,19 @@ export const lists: Lists = { // should not be publicly visible password: password({ access: { - read: isAdminOrSameUser, // TODO: is this required? + 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: { - // TODO: ? - fieldMode: args => (isAdmin(args) ? 'read' : 'hidden'), + fieldMode: 'hidden', // TODO: is this required? }, }, }), diff --git a/examples/custom-output-paths/my-types.ts b/examples/custom-output-paths/my-types.ts index 1340f95ad00..1c67e2aed46 100644 --- a/examples/custom-output-paths/my-types.ts +++ b/examples/custom-output-paths/my-types.ts @@ -147,10 +147,10 @@ type ResolvedPostUpdateInput = { }; export declare namespace Lists { - export type Post = import('@keystone-6/core').ListConfig; + export type Post = import('@keystone-6/core').ListConfig, any>; namespace Post { export type Item = import('./node_modules/.myprisma/client').Post; - export type TypeInfo = { + export type TypeInfo = { key: 'Post'; isSingleton: false; fields: 'id' | 'title' | 'content' | 'publishDate' @@ -166,23 +166,25 @@ export declare namespace Lists { create: ResolvedPostCreateInput; update: ResolvedPostUpdateInput; }; - all: __TypeInfo; + all: __TypeInfo; }; } } -export type Context = import('@keystone-6/core/types').KeystoneContext; +export type Context = import('@keystone-6/core/types').KeystoneContext>; +export type Config = import('@keystone-6/core/types').KeystoneConfig>; -export type TypeInfo = { +export type TypeInfo = { lists: { readonly Post: Lists.Post.TypeInfo; }; prisma: import('./node_modules/.myprisma/client').PrismaClient; + session: Session; }; -type __TypeInfo = TypeInfo; +type __TypeInfo = TypeInfo; -export type Lists = { - [Key in keyof TypeInfo['lists']]?: import('@keystone-6/core').ListConfig +export type Lists = { + [Key in keyof TypeInfo['lists']]?: import('@keystone-6/core').ListConfig['lists'][Key], any> } & Record>; export {} diff --git a/examples/custom-session-validation/README.md b/examples/custom-session-invalidation/README.md similarity index 100% rename from examples/custom-session-validation/README.md rename to examples/custom-session-invalidation/README.md diff --git a/examples/custom-session-invalidation/keystone.ts b/examples/custom-session-invalidation/keystone.ts new file mode 100644 index 00000000000..8579d0a9dc6 --- /dev/null +++ b/examples/custom-session-invalidation/keystone.ts @@ -0,0 +1,97 @@ +import { config } from '@keystone-6/core'; +import { statelessSessions } from '@keystone-6/core/session'; +import { createAuth } from '@keystone-6/auth'; +import { fixPrismaPath } from '../example-utils'; +import { lists, Session } from './schema'; +import type { Config, 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 30 days for this example +const sessionMaxAge = 60 * 60 * 24 * 30; + +// 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'], + }, + + sessionData: 'passwordChangedAt', +}); + +function withSessionInvalidation(config: Config) { + const existingSessionStrategy = config.session!; + + return { + ...config, + session: { + ...config.session, + async get({ context }: { context: Context }): Promise { + const session = await existingSessionStrategy.get({ context }); + if (!session) return; + + // has the password changed since the session started? + if (new Date(session.data.passwordChangedAt) > new Date(session.startedAt)) { + // invalidate the session if password changed + await existingSessionStrategy.end({ context }); + return; + } + + return session; + }, + async start({ context, data }: { context: Context; data: Session }) { + return await existingSessionStrategy.start({ + context, + data: { + ...data, + startedAt: Date.now(), + }, + }); + }, + }, + }; +} + +export default withSessionInvalidation( + 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 + ...fixPrismaPath, + }, + lists, + // you can find out more at https://keystonejs.com/docs/apis/session#session-api + session: 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, + }), + }) + ) +); diff --git a/examples/custom-session-validation/package.json b/examples/custom-session-invalidation/package.json similarity index 70% rename from examples/custom-session-validation/package.json rename to examples/custom-session-invalidation/package.json index 55200cf1c6c..23de5b2f202 100644 --- a/examples/custom-session-validation/package.json +++ b/examples/custom-session-invalidation/package.json @@ -1,5 +1,5 @@ { - "name": "@keystone-6/example-with-custom-session-validation", + "name": "@keystone-6/example-custom-session-invalidation", "version": "0.0.5", "private": true, "license": "MIT", @@ -17,6 +17,5 @@ "devDependencies": { "prisma": "^4.14.0", "typescript": "~5.0.0" - }, - "repository": "https://github.com/keystonejs/keystone/tree/main/examples/custom-session-validation" + } } diff --git a/examples/custom-session-validation/sandbox.config.json b/examples/custom-session-invalidation/sandbox.config.json similarity index 100% rename from examples/custom-session-validation/sandbox.config.json rename to examples/custom-session-invalidation/sandbox.config.json diff --git a/examples/custom-session-invalidation/schema.graphql b/examples/custom-session-invalidation/schema.graphql new file mode 100644 index 00000000000..0d14c689015 --- /dev/null +++ b/examples/custom-session-invalidation/schema.graphql @@ -0,0 +1,214 @@ +# 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 + passwordChangedAt: DateTime +} + +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 +} + +input IDFilter { + equals: ID + in: [ID!] + notIn: [ID!] + lt: ID + lte: ID + gt: ID + gte: ID + not: IDFilter +} + +input UserOrderByInput { + id: OrderDirection + passwordChangedAt: OrderDirection +} + +enum OrderDirection { + asc + desc +} + +input UserUpdateInput { + name: String + password: String + passwordChangedAt: DateTime +} + +input UserUpdateArgs { + where: UserWhereUniqueInput! + data: UserUpdateInput! +} + +input UserCreateInput { + name: String + password: String + passwordChangedAt: DateTime +} + +""" +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 { + users(where: UserWhereInput! = {}, orderBy: [UserOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: UserWhereUniqueInput): [User!] + user(where: UserWhereUniqueInput!): User + usersCount(where: UserWhereInput! = {}): Int + keystone: KeystoneMeta! + authenticatedItem: AuthenticatedItem +} + +union AuthenticatedItem = User + +type KeystoneMeta { + adminMeta: KeystoneAdminMeta! +} + +type KeystoneAdminMeta { + lists: [KeystoneAdminUIListMeta!]! + list(key: String!): KeystoneAdminUIListMeta +} + +type KeystoneAdminUIListMeta { + key: String! + itemQueryName: String! + listQueryName: String! + hideCreate: Boolean! + hideDelete: Boolean! + path: String! + label: String! + singular: String! + plural: String! + description: String + initialColumns: [String!]! + pageSize: Int! + labelField: String! + fields: [KeystoneAdminUIFieldMeta!]! + groups: [KeystoneAdminUIFieldGroupMeta!]! + initialSort: KeystoneAdminUISort + isHidden: Boolean! + isSingleton: Boolean! +} + +type KeystoneAdminUIFieldMeta { + path: String! + label: String! + description: String + isOrderable: Boolean! + isFilterable: Boolean! + isNonNull: [KeystoneAdminUIFieldMetaIsNonNull!] + fieldMeta: JSON + viewsIndex: Int! + customViewsIndex: Int + createView: KeystoneAdminUIFieldMetaCreateView! + listView: KeystoneAdminUIFieldMetaListView! + itemView(id: ID): KeystoneAdminUIFieldMetaItemView + search: QueryMode +} + +enum KeystoneAdminUIFieldMetaIsNonNull { + read + create + update +} + +type KeystoneAdminUIFieldMetaCreateView { + fieldMode: KeystoneAdminUIFieldMetaCreateViewFieldMode! +} + +enum KeystoneAdminUIFieldMetaCreateViewFieldMode { + edit + hidden +} + +type KeystoneAdminUIFieldMetaListView { + fieldMode: KeystoneAdminUIFieldMetaListViewFieldMode! +} + +enum KeystoneAdminUIFieldMetaListViewFieldMode { + read + hidden +} + +type KeystoneAdminUIFieldMetaItemView { + fieldMode: KeystoneAdminUIFieldMetaItemViewFieldMode + fieldPosition: KeystoneAdminUIFieldMetaItemViewFieldPosition +} + +enum KeystoneAdminUIFieldMetaItemViewFieldMode { + edit + read + hidden +} + +enum KeystoneAdminUIFieldMetaItemViewFieldPosition { + form + sidebar +} + +enum QueryMode { + default + insensitive +} + +type KeystoneAdminUIFieldGroupMeta { + label: String! + description: String + fields: [KeystoneAdminUIFieldMeta!]! +} + +type KeystoneAdminUISort { + field: String! + direction: KeystoneAdminUISortDirection! +} + +enum KeystoneAdminUISortDirection { + ASC + DESC +} diff --git a/examples/custom-session-invalidation/schema.prisma b/examples/custom-session-invalidation/schema.prisma new file mode 100644 index 00000000000..0a1a98ffc9b --- /dev/null +++ b/examples/custom-session-invalidation/schema.prisma @@ -0,0 +1,20 @@ +// 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/client" +} + +model User { + id String @id @default(cuid()) + name String @unique @default("") + password String + passwordChangedAt DateTime? +} diff --git a/examples/custom-session-invalidation/schema.ts b/examples/custom-session-invalidation/schema.ts new file mode 100644 index 00000000000..8b33d064a5a --- /dev/null +++ b/examples/custom-session-invalidation/schema.ts @@ -0,0 +1,88 @@ +import { list } from '@keystone-6/core'; +import { denyAll, unfiltered } from '@keystone-6/core/access'; +import { text, password, timestamp } from '@keystone-6/core/fields'; +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 + +// needs to be compatible with withAuth +export type Session = { + listKey: string; + itemId: string; + data: { + passwordChangedAt: string; + }; + startedAt: number; +}; + +function hasSession({ session }: { session?: Session }) { + return Boolean(session); +} + +function isSameUserFilter({ session }: { session?: Session }) { + // you need to have a session + if (!session) return false; + + // the authenticated user can only see themselves + return { + id: { + equals: session.itemId, + }, + }; +} + +export const lists: Lists = { + User: list({ + access: { + operation: hasSession, + filter: { + query: unfiltered, + update: isSameUserFilter, + delete: isSameUserFilter, + }, + }, + fields: { + // the user's name, used as the identity field for authentication + name: text({ + isFilterable: false, + isOrderable: false, + isIndexed: 'unique', + validation: { + isRequired: true, + }, + }), + + // the user's password, used as the secret field for authentication + password: password({ + validation: { + isRequired: true, + }, + // TODO: is anything else required + }), + + // a passwordChangedAt field, invalidates a session if changed + passwordChangedAt: timestamp({ + access: denyAll, + isFilterable: false, + ui: { + createView: { fieldMode: 'hidden' }, + itemView: { fieldMode: 'hidden' }, + listView: { fieldMode: 'hidden' }, + }, + }), + }, + hooks: { + resolveInput: { + update: ({ resolvedData }) => { + if ('password' in resolvedData) { + resolvedData.passwordChangedAt = new Date(); + } + + return resolvedData; + }, + }, + }, + }), +}; diff --git a/examples/custom-session-redis/keystone.ts b/examples/custom-session-redis/keystone.ts index 2658a14d1c5..bd7ef292aba 100644 --- a/examples/custom-session-redis/keystone.ts +++ b/examples/custom-session-redis/keystone.ts @@ -3,52 +3,75 @@ import { storedSessions } from '@keystone-6/core/session'; import { createAuth } from '@keystone-6/auth'; import { createClient } from '@redis/client'; import { fixPrismaPath } from '../example-utils'; -import { lists } from './schema'; +import { lists, Session } from './schema'; +import type { TypeInfo } from '.keystone/types'; -// createAuth configures signin functionality based on the config below. Note this only implements -// authentication, i.e signing in as an item using identity and secret fields in a list. Session -// management and access control are controlled independently in the main keystone config. +// 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 30 days for this example +const sessionMaxAge = 60 * 60 * 24 * 30; + +// withAuth is a function we can use to wrap our base configuration const { withAuth } = createAuth({ - // This is the list that contains items people can sign in as - listKey: 'Person', - // The identity field is typically a username or email address - identityField: 'email', - // The secret field must be a password type field + // 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 turns on the "First User" experience, which prompts you to create a new user - // when there are no items in the list yet + // 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: { - // These fields are collected in the "Create First User" form - fields: ['name', 'email', 'password'], + // the following fields are used by the "Create First User" form + fields: ['name', 'password'], }, }); const redis = createClient(); -const session = storedSessions({ - store: ({ maxAge }) => ({ - async get(key) { - let result = await redis.get(key); - if (typeof result === 'string') { - return JSON.parse(result); - } - }, - async set(key, value) { - await redis.setEx(key, maxAge, JSON.stringify(value)); - }, - async delete(key) { - await redis.del(key); - }, - }), - // The session secret is used to encrypt cookie data (should be an environment variable) - secret: '-- EXAMPLE COOKIE SECRET; CHANGE ME --', -}); +function redisSessionStrategy() { + // you can find out more at https://keystonejs.com/docs/apis/session#session-api + return storedSessions({ + // 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, + + store: () => ({ + async get(sessionId) { + const result = await redis.get(sessionId); + if (!result) return; + + return JSON.parse(result) as Session; + }, + + async set(sessionId, data) { + // we use redis for our Session data, in JSON + await redis.setEx(sessionId, sessionMaxAge, JSON.stringify(data)); + }, + + async delete(sessionId) { + await redis.del(sessionId); + }, + }), + }); +} -// We wrap our config using the withAuth function. This will inject all -// the extra config required to add support for authentication in our system. export default withAuth( - config({ + config>({ db: { provider: 'sqlite', url: process.env.DATABASE_URL || 'file:./keystone-example.db', @@ -60,7 +83,6 @@ export default withAuth( ...fixPrismaPath, }, lists, - // We add our session configuration to the system here. - session, + session: redisSessionStrategy(), }) ); diff --git a/examples/custom-session-redis/package.json b/examples/custom-session-redis/package.json index df3697a2b20..07f770656b6 100644 --- a/examples/custom-session-redis/package.json +++ b/examples/custom-session-redis/package.json @@ -1,5 +1,5 @@ { - "name": "@keystone-6/redis-session-store-example", + "name": "@keystone-6/custom-session-redis", "version": "0.0.8", "private": true, "license": "MIT", @@ -18,6 +18,5 @@ "devDependencies": { "prisma": "^4.14.0", "typescript": "~5.0.0" - }, - "repository": "https://github.com/keystonejs/keystone/tree/main/examples/redis-session-store" + } } diff --git a/examples/custom-session-redis/schema.graphql b/examples/custom-session-redis/schema.graphql index 5df88d70ba8..c2b13378f9c 100644 --- a/examples/custom-session-redis/schema.graphql +++ b/examples/custom-session-redis/schema.graphql @@ -1,37 +1,25 @@ # This file is automatically generated by Keystone, do not modify it manually. # Modify your Keystone config when you want to change this. -type Task { +type User { id: ID! - label: String - priority: TaskPriorityType - isComplete: Boolean - assignedTo: Person - finishBy: DateTime + name: String + password: PasswordState } -enum TaskPriorityType { - low - medium - high +type PasswordState { + isSet: Boolean! } -scalar DateTime @specifiedBy(url: "https://datatracker.ietf.org/doc/html/rfc3339#section-5.6") - -input TaskWhereUniqueInput { +input UserWhereUniqueInput { id: ID } -input TaskWhereInput { - AND: [TaskWhereInput!] - OR: [TaskWhereInput!] - NOT: [TaskWhereInput!] +input UserWhereInput { + AND: [UserWhereInput!] + OR: [UserWhereInput!] + NOT: [UserWhereInput!] id: IDFilter - label: StringFilter - priority: TaskPriorityTypeNullableFilter - isComplete: BooleanFilter - assignedTo: PersonWhereInput - finishBy: DateTimeNullableFilter } input IDFilter { @@ -45,63 +33,8 @@ input IDFilter { not: IDFilter } -input StringFilter { - equals: String - in: [String!] - notIn: [String!] - lt: String - lte: String - gt: String - gte: String - contains: String - startsWith: String - endsWith: String - not: NestedStringFilter -} - -input NestedStringFilter { - equals: String - in: [String!] - notIn: [String!] - lt: String - lte: String - gt: String - gte: String - contains: String - startsWith: String - endsWith: String - not: NestedStringFilter -} - -input TaskPriorityTypeNullableFilter { - equals: TaskPriorityType - in: [TaskPriorityType!] - notIn: [TaskPriorityType!] - not: TaskPriorityTypeNullableFilter -} - -input BooleanFilter { - equals: Boolean - not: BooleanFilter -} - -input DateTimeNullableFilter { - equals: DateTime - in: [DateTime!] - notIn: [DateTime!] - lt: DateTime - lte: DateTime - gt: DateTime - gte: DateTime - not: DateTimeNullableFilter -} - -input TaskOrderByInput { +input UserOrderByInput { id: OrderDirection - label: OrderDirection - priority: OrderDirection - isComplete: OrderDirection - finishBy: OrderDirection } enum OrderDirection { @@ -109,107 +42,19 @@ enum OrderDirection { desc } -input TaskUpdateInput { - label: String - priority: TaskPriorityType - isComplete: Boolean - assignedTo: PersonRelateToOneForUpdateInput - finishBy: DateTime -} - -input PersonRelateToOneForUpdateInput { - create: PersonCreateInput - connect: PersonWhereUniqueInput - disconnect: Boolean -} - -input TaskUpdateArgs { - where: TaskWhereUniqueInput! - data: TaskUpdateInput! -} - -input TaskCreateInput { - label: String - priority: TaskPriorityType - isComplete: Boolean - assignedTo: PersonRelateToOneForCreateInput - finishBy: DateTime -} - -input PersonRelateToOneForCreateInput { - create: PersonCreateInput - connect: PersonWhereUniqueInput -} - -type Person { - id: ID! - name: String - email: String - password: PasswordState - tasks(where: TaskWhereInput! = {}, orderBy: [TaskOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: TaskWhereUniqueInput): [Task!] - tasksCount(where: TaskWhereInput! = {}): Int -} - -type PasswordState { - isSet: Boolean! -} - -input PersonWhereUniqueInput { - id: ID - email: String -} - -input PersonWhereInput { - AND: [PersonWhereInput!] - OR: [PersonWhereInput!] - NOT: [PersonWhereInput!] - id: IDFilter - name: StringFilter - email: StringFilter - tasks: TaskManyRelationFilter -} - -input TaskManyRelationFilter { - every: TaskWhereInput - some: TaskWhereInput - none: TaskWhereInput -} - -input PersonOrderByInput { - id: OrderDirection - name: OrderDirection - email: OrderDirection -} - -input PersonUpdateInput { +input UserUpdateInput { name: String - email: String password: String - tasks: TaskRelateToManyForUpdateInput -} - -input TaskRelateToManyForUpdateInput { - disconnect: [TaskWhereUniqueInput!] - set: [TaskWhereUniqueInput!] - create: [TaskCreateInput!] - connect: [TaskWhereUniqueInput!] } -input PersonUpdateArgs { - where: PersonWhereUniqueInput! - data: PersonUpdateInput! +input UserUpdateArgs { + where: UserWhereUniqueInput! + data: UserUpdateInput! } -input PersonCreateInput { +input UserCreateInput { name: String - email: String password: String - tasks: TaskRelateToManyForCreateInput -} - -input TaskRelateToManyForCreateInput { - create: [TaskCreateInput!] - connect: [TaskWhereUniqueInput!] } """ @@ -218,52 +63,42 @@ The `JSON` scalar type represents JSON values as specified by [ECMA-404](http:// scalar JSON @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf") type Mutation { - createTask(data: TaskCreateInput!): Task - createTasks(data: [TaskCreateInput!]!): [Task] - updateTask(where: TaskWhereUniqueInput!, data: TaskUpdateInput!): Task - updateTasks(data: [TaskUpdateArgs!]!): [Task] - deleteTask(where: TaskWhereUniqueInput!): Task - deleteTasks(where: [TaskWhereUniqueInput!]!): [Task] - createPerson(data: PersonCreateInput!): Person - createPeople(data: [PersonCreateInput!]!): [Person] - updatePerson(where: PersonWhereUniqueInput!, data: PersonUpdateInput!): Person - updatePeople(data: [PersonUpdateArgs!]!): [Person] - deletePerson(where: PersonWhereUniqueInput!): Person - deletePeople(where: [PersonWhereUniqueInput!]!): [Person] + 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! - authenticatePersonWithPassword(email: String!, password: String!): PersonAuthenticationWithPasswordResult - createInitialPerson(data: CreateInitialPersonInput!): PersonAuthenticationWithPasswordSuccess! + authenticateUserWithPassword(name: String!, password: String!): UserAuthenticationWithPasswordResult + createInitialUser(data: CreateInitialUserInput!): UserAuthenticationWithPasswordSuccess! } -union PersonAuthenticationWithPasswordResult = PersonAuthenticationWithPasswordSuccess | PersonAuthenticationWithPasswordFailure +union UserAuthenticationWithPasswordResult = UserAuthenticationWithPasswordSuccess | UserAuthenticationWithPasswordFailure -type PersonAuthenticationWithPasswordSuccess { +type UserAuthenticationWithPasswordSuccess { sessionToken: String! - item: Person! + item: User! } -type PersonAuthenticationWithPasswordFailure { +type UserAuthenticationWithPasswordFailure { message: String! } -input CreateInitialPersonInput { +input CreateInitialUserInput { name: String - email: String password: String } type Query { - tasks(where: TaskWhereInput! = {}, orderBy: [TaskOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: TaskWhereUniqueInput): [Task!] - task(where: TaskWhereUniqueInput!): Task - tasksCount(where: TaskWhereInput! = {}): Int - people(where: PersonWhereInput! = {}, orderBy: [PersonOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: PersonWhereUniqueInput): [Person!] - person(where: PersonWhereUniqueInput!): Person - peopleCount(where: PersonWhereInput! = {}): Int + users(where: UserWhereInput! = {}, orderBy: [UserOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: UserWhereUniqueInput): [User!] + user(where: UserWhereUniqueInput!): User + usersCount(where: UserWhereInput! = {}): Int keystone: KeystoneMeta! authenticatedItem: AuthenticatedItem } -union AuthenticatedItem = Person +union AuthenticatedItem = User type KeystoneMeta { adminMeta: KeystoneAdminMeta! diff --git a/examples/custom-session-redis/schema.prisma b/examples/custom-session-redis/schema.prisma index 01d35f09b57..3364a886215 100644 --- a/examples/custom-session-redis/schema.prisma +++ b/examples/custom-session-redis/schema.prisma @@ -12,22 +12,8 @@ generator client { output = "node_modules/.myprisma/client" } -model Task { - id String @id @default(cuid()) - label String @default("") - priority String? - isComplete Boolean @default(false) - assignedTo Person? @relation("Task_assignedTo", fields: [assignedToId], references: [id]) - assignedToId String? @map("assignedTo") - finishBy DateTime? - - @@index([assignedToId]) -} - -model Person { +model User { id String @id @default(cuid()) - name String @default("") - email String @unique @default("") + name String @unique @default("") password String - tasks Task[] @relation("Task_assignedTo") } diff --git a/examples/custom-session-redis/schema.ts b/examples/custom-session-redis/schema.ts index b9cc9cec287..27ea195926f 100644 --- a/examples/custom-session-redis/schema.ts +++ b/examples/custom-session-redis/schema.ts @@ -1,40 +1,65 @@ import { list } from '@keystone-6/core'; -import { allowAll } from '@keystone-6/core/access'; -import { checkbox, password, relationship, text, timestamp } from '@keystone-6/core/fields'; -import { select } from '@keystone-6/core/fields'; +import { unfiltered } from '@keystone-6/core/access'; +import { text, password } from '@keystone-6/core/fields'; 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 + +// needs to be compatible with withAuth +export type Session = { + listKey: string; + itemId: string; + data: { + something: string; + }; +}; + +function hasSession({ session }: { session?: Session }) { + return Boolean(session); +} + +function isSameUserFilter({ session }: { session?: Session }) { + // you need to have a session + if (!session) return false; + + // the authenticated user can only see themselves + return { + id: { + equals: session.itemId, + }, + }; +} + export const lists: Lists = { - Task: list({ - access: allowAll, - fields: { - label: text({ validation: { isRequired: true } }), - priority: select({ - type: 'enum', - options: [ - { label: 'Low', value: 'low' }, - { label: 'Medium', value: 'medium' }, - { label: 'High', value: 'high' }, - ], - }), - isComplete: checkbox(), - assignedTo: relationship({ ref: 'Person.tasks', many: false }), - finishBy: timestamp(), + User: list({ + access: { + operation: hasSession, + filter: { + query: unfiltered, + update: isSameUserFilter, + delete: isSameUserFilter, + }, }, - }), - Person: list({ - access: allowAll, fields: { - name: text({ validation: { isRequired: true } }), - // Added an email and password pair to be used with authentication - // The email address is going to be used as the identity field, so it's - // important that we set isRequired and isIndexed: 'unique'. - email: text({ isIndexed: 'unique', validation: { isRequired: true } }), - // The password field stores a hash of the supplied password, and - // we want to ensure that all people have a password set, so we use - // the validation.isRequired flag. - password: password({ validation: { isRequired: true } }), - tasks: relationship({ ref: 'Task.assignedTo', many: true }), + // the user's name, used as the identity field for authentication + name: text({ + isFilterable: false, + isOrderable: false, + isIndexed: 'unique', + validation: { + isRequired: true, + }, + }), + + // the user's password, used as the secret field for authentication + password: password({ + validation: { + isRequired: true, + }, + // TODO: is anything else required + }), }, }), }; diff --git a/examples/custom-session-validation/keystone.ts b/examples/custom-session-validation/keystone.ts deleted file mode 100644 index c74c066da24..00000000000 --- a/examples/custom-session-validation/keystone.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { KeystoneConfig, SessionStrategy } from '@keystone-6/core/types'; -import { config } from '@keystone-6/core'; -import { statelessSessions } from '@keystone-6/core/session'; -import { createAuth } from '@keystone-6/auth'; -import { fixPrismaPath } from '../example-utils'; -import { lists } from './schema'; - -// createAuth configures signin functionality based on the config below. Note this only implements -// authentication, i.e signing in as an item using identity and secret fields in a list. Session -// management and access control are controlled independently in the main keystone config. -const { withAuth } = createAuth({ - // This is the list that contains items people can sign in as - listKey: 'Person', - // The identity field is typically a username or email address - identityField: 'email', - // The secret field must be a password type field - secretField: 'password', - - // initFirstItem turns on the "First User" experience, which prompts you to create a new user - // when there are no items in the list yet - initFirstItem: { - // These fields are collected in the "Create First User" form - fields: ['name', 'email', 'password'], - }, - // Make passwordChangedAt available on the sesssion data - sessionData: 'id passwordChangedAt', -}); - -const maxSessionAge = 60 * 60 * 8; // 8 hours, in seconds -// Stateless sessions will store the listKey and itemId of the signed-in user in a cookie. -// This session object will be made available on the context object used in hooks, access-control, -// resolvers, etc. - -const withTimeData = ( - _sessionStrategy: SessionStrategy> -): SessionStrategy> => { - const { get, start, ...sessionStrategy } = _sessionStrategy; - return { - ...sessionStrategy, - get: async ({ context }) => { - // Get the session from the cookie stored by keystone - const session = await get({ context }); - // If there is no session returned from keystone or there is no startTime on the session return an invalid session - // If session.startTime is null session.data.passwordChangedAt > session.startTime will always be true and therefore - // the session will never be invalid until the maxSessionAge is reached. - if (!session || !session.startTime) return; - //if the password hasn't changed (and isn't missing), then the session is OK - if (session.data.passwordChangedAt === null) return session; - // If passwordChangeAt is undefined, then sessionData is missing the passwordChangedAt field - // Or something is wrong with the session configuration so throw and error - if (session.data.passwordChangedAt === undefined) { - throw new TypeError('passwordChangedAt is not listed in sessionData'); - } - if (session.data.passwordChangedAt > session.startTime) { - return; - } - - return session; - }, - start: async ({ data, context }) => { - // Add the current time to the session data - const withTimeData = { - ...data, - startTime: new Date(), - }; - // Start the keystone session and include the startTime - return await start({ data: withTimeData, context }); - }, - }; -}; - -const myAuth = (keystoneConfig: KeystoneConfig): KeystoneConfig => { - // Add the session strategy to the config - if (!keystoneConfig.session) throw new TypeError('Missing .session configuration'); - return { - ...keystoneConfig, - session: withTimeData(keystoneConfig.session), - }; -}; - -const session = statelessSessions({ - // The session secret is used to encrypt cookie data (should be an environment variable) - maxAge: maxSessionAge, - secret: '-- EXAMPLE COOKIE SECRET; CHANGE ME --', -}); - -// We wrap our config using the withAuth function. This will inject all -// the extra config required to add support for authentication in our system. -export default myAuth( - 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 - ...fixPrismaPath, - }, - lists, - // We add our session configuration to the system here. - session, - }) - ) -); diff --git a/examples/custom-session-validation/schema.graphql b/examples/custom-session-validation/schema.graphql deleted file mode 100644 index 78aa95b5c9c..00000000000 --- a/examples/custom-session-validation/schema.graphql +++ /dev/null @@ -1,377 +0,0 @@ -# This file is automatically generated by Keystone, do not modify it manually. -# Modify your Keystone config when you want to change this. - -type Task { - id: ID! - label: String - priority: TaskPriorityType - isComplete: Boolean - assignedTo: Person - finishBy: DateTime -} - -enum TaskPriorityType { - low - medium - high -} - -scalar DateTime @specifiedBy(url: "https://datatracker.ietf.org/doc/html/rfc3339#section-5.6") - -input TaskWhereUniqueInput { - id: ID -} - -input TaskWhereInput { - AND: [TaskWhereInput!] - OR: [TaskWhereInput!] - NOT: [TaskWhereInput!] - id: IDFilter - label: StringFilter - priority: TaskPriorityTypeNullableFilter - isComplete: BooleanFilter - assignedTo: PersonWhereInput - finishBy: DateTimeNullableFilter -} - -input IDFilter { - equals: ID - in: [ID!] - notIn: [ID!] - lt: ID - lte: ID - gt: ID - gte: ID - not: IDFilter -} - -input StringFilter { - equals: String - in: [String!] - notIn: [String!] - lt: String - lte: String - gt: String - gte: String - contains: String - startsWith: String - endsWith: String - not: NestedStringFilter -} - -input NestedStringFilter { - equals: String - in: [String!] - notIn: [String!] - lt: String - lte: String - gt: String - gte: String - contains: String - startsWith: String - endsWith: String - not: NestedStringFilter -} - -input TaskPriorityTypeNullableFilter { - equals: TaskPriorityType - in: [TaskPriorityType!] - notIn: [TaskPriorityType!] - not: TaskPriorityTypeNullableFilter -} - -input BooleanFilter { - equals: Boolean - not: BooleanFilter -} - -input DateTimeNullableFilter { - equals: DateTime - in: [DateTime!] - notIn: [DateTime!] - lt: DateTime - lte: DateTime - gt: DateTime - gte: DateTime - not: DateTimeNullableFilter -} - -input TaskOrderByInput { - id: OrderDirection - label: OrderDirection - priority: OrderDirection - isComplete: OrderDirection - finishBy: OrderDirection -} - -enum OrderDirection { - asc - desc -} - -input TaskUpdateInput { - label: String - priority: TaskPriorityType - isComplete: Boolean - assignedTo: PersonRelateToOneForUpdateInput - finishBy: DateTime -} - -input PersonRelateToOneForUpdateInput { - create: PersonCreateInput - connect: PersonWhereUniqueInput - disconnect: Boolean -} - -input TaskUpdateArgs { - where: TaskWhereUniqueInput! - data: TaskUpdateInput! -} - -input TaskCreateInput { - label: String - priority: TaskPriorityType - isComplete: Boolean - assignedTo: PersonRelateToOneForCreateInput - finishBy: DateTime -} - -input PersonRelateToOneForCreateInput { - create: PersonCreateInput - connect: PersonWhereUniqueInput -} - -type Person { - id: ID! - name: String - email: String - password: PasswordState - passwordChangedAt: DateTime - tasks(where: TaskWhereInput! = {}, orderBy: [TaskOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: TaskWhereUniqueInput): [Task!] - tasksCount(where: TaskWhereInput! = {}): Int -} - -type PasswordState { - isSet: Boolean! -} - -input PersonWhereUniqueInput { - id: ID - email: String -} - -input PersonWhereInput { - AND: [PersonWhereInput!] - OR: [PersonWhereInput!] - NOT: [PersonWhereInput!] - id: IDFilter - name: StringFilter - email: StringFilter - tasks: TaskManyRelationFilter -} - -input TaskManyRelationFilter { - every: TaskWhereInput - some: TaskWhereInput - none: TaskWhereInput -} - -input PersonOrderByInput { - id: OrderDirection - name: OrderDirection - email: OrderDirection - passwordChangedAt: OrderDirection -} - -input PersonUpdateInput { - name: String - email: String - password: String - passwordChangedAt: DateTime - tasks: TaskRelateToManyForUpdateInput -} - -input TaskRelateToManyForUpdateInput { - disconnect: [TaskWhereUniqueInput!] - set: [TaskWhereUniqueInput!] - create: [TaskCreateInput!] - connect: [TaskWhereUniqueInput!] -} - -input PersonUpdateArgs { - where: PersonWhereUniqueInput! - data: PersonUpdateInput! -} - -input PersonCreateInput { - name: String - email: String - password: String - passwordChangedAt: DateTime - tasks: TaskRelateToManyForCreateInput -} - -input TaskRelateToManyForCreateInput { - create: [TaskCreateInput!] - connect: [TaskWhereUniqueInput!] -} - -""" -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 { - createTask(data: TaskCreateInput!): Task - createTasks(data: [TaskCreateInput!]!): [Task] - updateTask(where: TaskWhereUniqueInput!, data: TaskUpdateInput!): Task - updateTasks(data: [TaskUpdateArgs!]!): [Task] - deleteTask(where: TaskWhereUniqueInput!): Task - deleteTasks(where: [TaskWhereUniqueInput!]!): [Task] - createPerson(data: PersonCreateInput!): Person - createPeople(data: [PersonCreateInput!]!): [Person] - updatePerson(where: PersonWhereUniqueInput!, data: PersonUpdateInput!): Person - updatePeople(data: [PersonUpdateArgs!]!): [Person] - deletePerson(where: PersonWhereUniqueInput!): Person - deletePeople(where: [PersonWhereUniqueInput!]!): [Person] - endSession: Boolean! - authenticatePersonWithPassword(email: String!, password: String!): PersonAuthenticationWithPasswordResult - createInitialPerson(data: CreateInitialPersonInput!): PersonAuthenticationWithPasswordSuccess! -} - -union PersonAuthenticationWithPasswordResult = PersonAuthenticationWithPasswordSuccess | PersonAuthenticationWithPasswordFailure - -type PersonAuthenticationWithPasswordSuccess { - sessionToken: String! - item: Person! -} - -type PersonAuthenticationWithPasswordFailure { - message: String! -} - -input CreateInitialPersonInput { - name: String - email: String - password: String -} - -type Query { - tasks(where: TaskWhereInput! = {}, orderBy: [TaskOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: TaskWhereUniqueInput): [Task!] - task(where: TaskWhereUniqueInput!): Task - tasksCount(where: TaskWhereInput! = {}): Int - people(where: PersonWhereInput! = {}, orderBy: [PersonOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: PersonWhereUniqueInput): [Person!] - person(where: PersonWhereUniqueInput!): Person - peopleCount(where: PersonWhereInput! = {}): Int - keystone: KeystoneMeta! - authenticatedItem: AuthenticatedItem -} - -union AuthenticatedItem = Person - -type KeystoneMeta { - adminMeta: KeystoneAdminMeta! -} - -type KeystoneAdminMeta { - lists: [KeystoneAdminUIListMeta!]! - list(key: String!): KeystoneAdminUIListMeta -} - -type KeystoneAdminUIListMeta { - key: String! - itemQueryName: String! - listQueryName: String! - hideCreate: Boolean! - hideDelete: Boolean! - path: String! - label: String! - singular: String! - plural: String! - description: String - initialColumns: [String!]! - pageSize: Int! - labelField: String! - fields: [KeystoneAdminUIFieldMeta!]! - groups: [KeystoneAdminUIFieldGroupMeta!]! - initialSort: KeystoneAdminUISort - isHidden: Boolean! - isSingleton: Boolean! -} - -type KeystoneAdminUIFieldMeta { - path: String! - label: String! - description: String - isOrderable: Boolean! - isFilterable: Boolean! - isNonNull: [KeystoneAdminUIFieldMetaIsNonNull!] - fieldMeta: JSON - viewsIndex: Int! - customViewsIndex: Int - createView: KeystoneAdminUIFieldMetaCreateView! - listView: KeystoneAdminUIFieldMetaListView! - itemView(id: ID): KeystoneAdminUIFieldMetaItemView - search: QueryMode -} - -enum KeystoneAdminUIFieldMetaIsNonNull { - read - create - update -} - -type KeystoneAdminUIFieldMetaCreateView { - fieldMode: KeystoneAdminUIFieldMetaCreateViewFieldMode! -} - -enum KeystoneAdminUIFieldMetaCreateViewFieldMode { - edit - hidden -} - -type KeystoneAdminUIFieldMetaListView { - fieldMode: KeystoneAdminUIFieldMetaListViewFieldMode! -} - -enum KeystoneAdminUIFieldMetaListViewFieldMode { - read - hidden -} - -type KeystoneAdminUIFieldMetaItemView { - fieldMode: KeystoneAdminUIFieldMetaItemViewFieldMode - fieldPosition: KeystoneAdminUIFieldMetaItemViewFieldPosition -} - -enum KeystoneAdminUIFieldMetaItemViewFieldMode { - edit - read - hidden -} - -enum KeystoneAdminUIFieldMetaItemViewFieldPosition { - form - sidebar -} - -enum QueryMode { - default - insensitive -} - -type KeystoneAdminUIFieldGroupMeta { - label: String! - description: String - fields: [KeystoneAdminUIFieldMeta!]! -} - -type KeystoneAdminUISort { - field: String! - direction: KeystoneAdminUISortDirection! -} - -enum KeystoneAdminUISortDirection { - ASC - DESC -} diff --git a/examples/custom-session-validation/schema.prisma b/examples/custom-session-validation/schema.prisma deleted file mode 100644 index 7cd99fdf278..00000000000 --- a/examples/custom-session-validation/schema.prisma +++ /dev/null @@ -1,34 +0,0 @@ -// 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/client" -} - -model Task { - id String @id @default(cuid()) - label String @default("") - priority String? - isComplete Boolean @default(false) - assignedTo Person? @relation("Task_assignedTo", fields: [assignedToId], references: [id]) - assignedToId String? @map("assignedTo") - finishBy DateTime? - - @@index([assignedToId]) -} - -model Person { - id String @id @default(cuid()) - name String @default("") - email String @unique @default("") - password String - passwordChangedAt DateTime? - tasks Task[] @relation("Task_assignedTo") -} diff --git a/examples/custom-session-validation/schema.ts b/examples/custom-session-validation/schema.ts deleted file mode 100644 index b838dade7c6..00000000000 --- a/examples/custom-session-validation/schema.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { list } from '@keystone-6/core'; -import { allowAll } from '@keystone-6/core/access'; -import { checkbox, password, relationship, text, timestamp } from '@keystone-6/core/fields'; -import { select } from '@keystone-6/core/fields'; - -export const lists = { - Task: list({ - access: allowAll, - fields: { - label: text({ validation: { isRequired: true } }), - priority: select({ - type: 'enum', - options: [ - { label: 'Low', value: 'low' }, - { label: 'Medium', value: 'medium' }, - { label: 'High', value: 'high' }, - ], - }), - isComplete: checkbox(), - assignedTo: relationship({ ref: 'Person.tasks', many: false }), - finishBy: timestamp(), - }, - }), - Person: list({ - access: allowAll, - fields: { - name: text({ validation: { isRequired: true } }), - // Added an email and password pair to be used with authentication - // The email address is going to be used as the identity field, so it's - // important that we set isRequired and isIndexed: 'unique'. - email: text({ isIndexed: 'unique', validation: { isRequired: true } }), - // The password field stores a hash of the supplied password, and - // we want to ensure that all people have a password set, so we use - // the validation.isRequired flag. - password: password({ - validation: { isRequired: true }, - }), - // Added a passwordChangedAt field that is updated using the resolveInput hook whenever the password is changed. - // This value is checked against the session startTime to determine if the password has been changed since the - // session was started, and if so invalidate the session. - passwordChangedAt: timestamp({ - // Don't allow the passwordChangedAt field to be set by the user. - access: () => false, - isFilterable: false, - hooks: { - resolveInput: ({ resolvedData }) => { - // If the password has been changed, update the passwordChangedAt field to the current time. - if (resolvedData.password) { - return new Date(); - } - // Otherwise return undefined, do nothing. - return; - }, - }, - ui: { - // Hide the passwordChangedAt field from the UI. - createView: { fieldMode: 'hidden' }, - itemView: { fieldMode: 'hidden' }, - listView: { fieldMode: 'hidden' }, - }, - }), - tasks: relationship({ ref: 'Task.assignedTo', many: true }), - }, - }), -}; diff --git a/examples/custom-session/schema.ts b/examples/custom-session/schema.ts index 5544f6513f5..0464173fa0b 100644 --- a/examples/custom-session/schema.ts +++ b/examples/custom-session/schema.ts @@ -8,16 +8,16 @@ export type Session = { admin: boolean; }; -function hasSession({ session }: { session: Session | undefined }) { +function hasSession({ session }: { session?: Session }) { return Boolean(session); } -function isAdmin({ session }: { session: Session | undefined }) { +function isAdmin({ session }: { session?: Session }) { if (!session) return false; return session.admin; } -function isAdminOrOnlySameUser({ session }: { session: Session | undefined }) { +function isAdminOrOnlySameUser({ session }: { session?: Session }) { if (!session) return false; if (session.admin) return {}; // unfiltered for admins return { diff --git a/examples/extend-graphql-schema-nexus/keystone-types.ts b/examples/extend-graphql-schema-nexus/keystone-types.ts index 18e45d22724..ca76846efe1 100644 --- a/examples/extend-graphql-schema-nexus/keystone-types.ts +++ b/examples/extend-graphql-schema-nexus/keystone-types.ts @@ -133,7 +133,6 @@ export type AuthorRelateToOneForCreateInput = { export type AuthorWhereUniqueInput = { readonly id?: Scalars['ID'] | null; - readonly email?: Scalars['String'] | null; }; export type AuthorWhereInput = { @@ -142,7 +141,6 @@ export type AuthorWhereInput = { readonly NOT?: ReadonlyArray | AuthorWhereInput | null; readonly id?: IDFilter | null; readonly name?: StringFilter | null; - readonly email?: StringFilter | null; readonly posts?: PostManyRelationFilter | null; }; @@ -155,12 +153,10 @@ export type PostManyRelationFilter = { export type AuthorOrderByInput = { readonly id?: OrderDirection | null; readonly name?: OrderDirection | null; - readonly email?: OrderDirection | null; }; export type AuthorUpdateInput = { readonly name?: Scalars['String'] | null; - readonly email?: Scalars['String'] | null; readonly posts?: PostRelateToManyForUpdateInput | null; }; @@ -178,7 +174,6 @@ export type AuthorUpdateArgs = { export type AuthorCreateInput = { readonly name?: Scalars['String'] | null; - readonly email?: Scalars['String'] | null; readonly posts?: PostRelateToManyForCreateInput | null; }; @@ -238,22 +233,20 @@ type ResolvedPostUpdateInput = { type ResolvedAuthorCreateInput = { id?: undefined; name?: import('./node_modules/.myprisma/client').Prisma.AuthorCreateInput['name']; - email?: import('./node_modules/.myprisma/client').Prisma.AuthorCreateInput['email']; posts?: import('./node_modules/.myprisma/client').Prisma.AuthorCreateInput['posts']; }; type ResolvedAuthorUpdateInput = { id?: undefined; name?: import('./node_modules/.myprisma/client').Prisma.AuthorUpdateInput['name']; - email?: import('./node_modules/.myprisma/client').Prisma.AuthorUpdateInput['email']; posts?: import('./node_modules/.myprisma/client').Prisma.AuthorUpdateInput['posts']; }; export declare namespace Lists { - export type Post = import('@keystone-6/core').ListConfig; + export type Post = import('@keystone-6/core').ListConfig, any>; namespace Post { export type Item = import('./node_modules/.myprisma/client').Post; - export type TypeInfo = { + export type TypeInfo = { key: 'Post'; isSingleton: false; fields: 'id' | 'title' | 'status' | 'content' | 'publishDate' | 'author' @@ -269,16 +262,16 @@ export declare namespace Lists { create: ResolvedPostCreateInput; update: ResolvedPostUpdateInput; }; - all: __TypeInfo; + all: __TypeInfo; }; } - export type Author = import('@keystone-6/core').ListConfig; + export type Author = import('@keystone-6/core').ListConfig, any>; namespace Author { export type Item = import('./node_modules/.myprisma/client').Author; - export type TypeInfo = { + export type TypeInfo = { key: 'Author'; isSingleton: false; - fields: 'id' | 'name' | 'email' | 'posts' + fields: 'id' | 'name' | 'posts' item: Item; inputs: { where: AuthorWhereInput; @@ -291,24 +284,26 @@ export declare namespace Lists { create: ResolvedAuthorCreateInput; update: ResolvedAuthorUpdateInput; }; - all: __TypeInfo; + all: __TypeInfo; }; } } -export type Context = import('@keystone-6/core/types').KeystoneContext; +export type Context = import('@keystone-6/core/types').KeystoneContext>; +export type Config = import('@keystone-6/core/types').KeystoneConfig>; -export type TypeInfo = { +export type TypeInfo = { lists: { readonly Post: Lists.Post.TypeInfo; readonly Author: Lists.Author.TypeInfo; }; prisma: import('./node_modules/.myprisma/client').PrismaClient; + session: Session; }; -type __TypeInfo = TypeInfo; +type __TypeInfo = TypeInfo; -export type Lists = { - [Key in keyof TypeInfo['lists']]?: import('@keystone-6/core').ListConfig +export type Lists = { + [Key in keyof TypeInfo['lists']]?: import('@keystone-6/core').ListConfig['lists'][Key], any> } & Record>; export {} diff --git a/examples/extend-graphql-schema-nexus/schema.graphql b/examples/extend-graphql-schema-nexus/schema.graphql index 4891df18c4d..5108ae391dd 100644 --- a/examples/extend-graphql-schema-nexus/schema.graphql +++ b/examples/extend-graphql-schema-nexus/schema.graphql @@ -138,14 +138,12 @@ input AuthorRelateToOneForCreateInput { type Author { id: ID! name: String - email: String posts(where: PostWhereInput! = {}, orderBy: [PostOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: PostWhereUniqueInput): [Post!] postsCount(where: PostWhereInput! = {}): Int } input AuthorWhereUniqueInput { id: ID - email: String } input AuthorWhereInput { @@ -154,7 +152,6 @@ input AuthorWhereInput { NOT: [AuthorWhereInput!] id: IDFilter name: StringFilter - email: StringFilter posts: PostManyRelationFilter } @@ -167,12 +164,10 @@ input PostManyRelationFilter { input AuthorOrderByInput { id: OrderDirection name: OrderDirection - email: OrderDirection } input AuthorUpdateInput { name: String - email: String posts: PostRelateToManyForUpdateInput } @@ -190,7 +185,6 @@ input AuthorUpdateArgs { input AuthorCreateInput { name: String - email: String posts: PostRelateToManyForCreateInput } diff --git a/examples/extend-graphql-schema-nexus/schema.prisma b/examples/extend-graphql-schema-nexus/schema.prisma index ef08f4c10bf..558a117135b 100644 --- a/examples/extend-graphql-schema-nexus/schema.prisma +++ b/examples/extend-graphql-schema-nexus/schema.prisma @@ -27,6 +27,5 @@ model Post { model Author { id String @id @default(cuid()) name String @default("") - email String @unique @default("") posts Post[] @relation("Post_author") } diff --git a/examples/extend-graphql-schema-nexus/schema.ts b/examples/extend-graphql-schema-nexus/schema.ts index a4b87994cdb..db796f0938b 100644 --- a/examples/extend-graphql-schema-nexus/schema.ts +++ b/examples/extend-graphql-schema-nexus/schema.ts @@ -27,7 +27,6 @@ export const lists: Lists = { access: allowAll, fields: { name: text({ validation: { isRequired: true } }), - email: text({ isIndexed: 'unique', validation: { isRequired: true } }), posts: relationship({ ref: 'Post.author', many: true }), }, }), diff --git a/examples/testing/example.test.ts b/examples/testing/example.test.ts index 18b16fb734c..c8cb46c42e6 100644 --- a/examples/testing/example.test.ts +++ b/examples/testing/example.test.ts @@ -14,15 +14,14 @@ beforeEach(async () => { await resetDatabase(dbUrl, prismaSchemaPath); }); -test('Create a Person using the Query API', async () => { +test('Create a User using the Query API', async () => { // We can use the context argument provided by the test runner to access // the full context API. - const person = await context.query.Person.createOne({ - data: { name: 'Alice', email: 'alice@example.com', password: 'super-secret' }, - query: 'id name email password { isSet }', + const person = await context.query.User.createOne({ + data: { name: 'Alice', password: 'dont-use-me' }, + query: 'id name password { isSet }', }); expect(person.name).toEqual('Alice'); - expect(person.email).toEqual('alice@example.com'); expect(person.password.isSet).toEqual(true); }); @@ -31,16 +30,16 @@ test('Check that trying to create user with no name (required field) fails', asy // error from an operation. const { data, errors } = (await context.graphql.raw({ query: `mutation { - createPerson(data: { email: "alice@example.com", password: "super-secret" }) { - id name email password { isSet } + createUser(data: { password: "dont-use-me" }) { + id name password { isSet } } }`, })) as any; - expect(data!.createPerson).toBe(null); + expect(data!.createUser).toBe(null); expect(errors).toHaveLength(1); - expect(errors![0].path).toEqual(['createPerson']); + expect(errors![0].path).toEqual(['createUser']); expect(errors![0].message).toEqual( - 'You provided invalid data for this operation.\n - Person.name: Name must not be empty' + 'You provided invalid data for this operation.\n - User.name: Name must not be empty' ); }); @@ -50,10 +49,10 @@ test('Check access control by running updateTask as a specific user via context. // are behaving as expected. // Create some users - const [alice, bob] = await context.query.Person.createMany({ + const [alice, bob] = await context.query.User.createMany({ data: [ - { name: 'Alice', email: 'alice@example.com', password: 'super-secret' }, - { name: 'Bob', email: 'bob@example.com', password: 'super-secret' }, + { name: 'Alice', password: 'dont-use-me' }, + { name: 'Bob', password: 'dont-use-me' }, ], query: 'id name', }); @@ -96,7 +95,7 @@ test('Check access control by running updateTask as a specific user via context. { // Check that we can update the task when logged in as Alice const { data, errors } = (await context - .withSession({ itemId: alice.id, data: {} }) + .withSession({ listKey: 'User', itemId: alice.id, data: {} }) .graphql.raw({ query: `mutation update($id: ID!) { updateTask(where: { id: $id }, data: { isComplete: true }) { @@ -111,14 +110,16 @@ test('Check access control by running updateTask as a specific user via context. // Check that we can't update the task when logged in as Bob { - const { data, errors } = (await context.withSession({ itemId: bob.id, data: {} }).graphql.raw({ - query: `mutation update($id: ID!) { + const { data, errors } = (await context + .withSession({ listKey: 'User', itemId: bob.id, data: {} }) + .graphql.raw({ + query: `mutation update($id: ID!) { updateTask(where: { id: $id }, data: { isComplete: true }) { id } }`, - variables: { id: task.id }, - })) as any; + variables: { id: task.id }, + })) as any; expect(data!.updateTask).toBe(null); expect(errors).toHaveLength(1); expect(errors![0].path).toEqual(['updateTask']); diff --git a/examples/testing/keystone.ts b/examples/testing/keystone.ts index 4ff0724353b..f5e44d803eb 100644 --- a/examples/testing/keystone.ts +++ b/examples/testing/keystone.ts @@ -1,41 +1,42 @@ import { config } from '@keystone-6/core'; import { statelessSessions } from '@keystone-6/core/session'; import { createAuth } from '@keystone-6/auth'; -import { fixPrismaPath } from './../example-utils'; +import { fixPrismaPath } from '../example-utils'; import { lists } from './schema'; -import { TypeInfo } from '.keystone/types'; +import type { Session } from './schema'; +import type { TypeInfo } from '.keystone/types'; -// createAuth configures signin functionality based on the config below. Note this only implements -// authentication, i.e signing in as an item using identity and secret fields in a list. Session -// management and access control are controlled independently in the main keystone config. +// WARNING: this example is for TESTING 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 --'; + +// withAuth is a function we can use to wrap our base configuration const { withAuth } = createAuth({ - // This is the list that contains items people can sign in as - listKey: 'Person', - // The identity field is typically a username or email address - identityField: 'email', - // The secret field must be a password type field + // 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 turns on the "First User" experience, which prompts you to create a new user - // when there are no items in the list yet + // 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: { - // These fields are collected in the "Create First User" form - fields: ['name', 'email', 'password'], + // the following fields are used by the "Create First User" form + fields: ['name', 'password'], }, }); -// Stateless sessions will store the listKey and itemId of the signed-in user in a cookie. -// This session object will be made available on the context object used in hooks, access-control, -// resolvers, etc. -const session = statelessSessions({ - // The session secret is used to encrypt cookie data (should be an environment variable) - secret: '-- EXAMPLE COOKIE SECRET; CHANGE ME --', -}); - -// We wrap our config using the withAuth function. This will inject all -// the extra config required to add support for authentication in our system. export default withAuth( - config({ + config>({ db: { provider: 'sqlite', url: process.env.DATABASE_URL || 'file:./keystone-example.db', @@ -44,7 +45,10 @@ export default withAuth( ...fixPrismaPath, }, lists, - // We add our session configuration to the system here. - session, + // you can find out more at https://keystonejs.com/docs/apis/session#session-api + session: statelessSessions({ + // the session secret is used to encrypt cookie data + secret: sessionSecret, + }), }) ); diff --git a/examples/testing/schema.graphql b/examples/testing/schema.graphql index 5df88d70ba8..3f6eac5c130 100644 --- a/examples/testing/schema.graphql +++ b/examples/testing/schema.graphql @@ -6,7 +6,7 @@ type Task { label: String priority: TaskPriorityType isComplete: Boolean - assignedTo: Person + assignedTo: User finishBy: DateTime } @@ -30,7 +30,7 @@ input TaskWhereInput { label: StringFilter priority: TaskPriorityTypeNullableFilter isComplete: BooleanFilter - assignedTo: PersonWhereInput + assignedTo: UserWhereInput finishBy: DateTimeNullableFilter } @@ -113,13 +113,13 @@ input TaskUpdateInput { label: String priority: TaskPriorityType isComplete: Boolean - assignedTo: PersonRelateToOneForUpdateInput + assignedTo: UserRelateToOneForUpdateInput finishBy: DateTime } -input PersonRelateToOneForUpdateInput { - create: PersonCreateInput - connect: PersonWhereUniqueInput +input UserRelateToOneForUpdateInput { + create: UserCreateInput + connect: UserWhereUniqueInput disconnect: Boolean } @@ -132,19 +132,18 @@ input TaskCreateInput { label: String priority: TaskPriorityType isComplete: Boolean - assignedTo: PersonRelateToOneForCreateInput + assignedTo: UserRelateToOneForCreateInput finishBy: DateTime } -input PersonRelateToOneForCreateInput { - create: PersonCreateInput - connect: PersonWhereUniqueInput +input UserRelateToOneForCreateInput { + create: UserCreateInput + connect: UserWhereUniqueInput } -type Person { +type User { id: ID! name: String - email: String password: PasswordState tasks(where: TaskWhereInput! = {}, orderBy: [TaskOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: TaskWhereUniqueInput): [Task!] tasksCount(where: TaskWhereInput! = {}): Int @@ -154,18 +153,17 @@ type PasswordState { isSet: Boolean! } -input PersonWhereUniqueInput { +input UserWhereUniqueInput { id: ID - email: String + name: String } -input PersonWhereInput { - AND: [PersonWhereInput!] - OR: [PersonWhereInput!] - NOT: [PersonWhereInput!] +input UserWhereInput { + AND: [UserWhereInput!] + OR: [UserWhereInput!] + NOT: [UserWhereInput!] id: IDFilter name: StringFilter - email: StringFilter tasks: TaskManyRelationFilter } @@ -175,15 +173,13 @@ input TaskManyRelationFilter { none: TaskWhereInput } -input PersonOrderByInput { +input UserOrderByInput { id: OrderDirection name: OrderDirection - email: OrderDirection } -input PersonUpdateInput { +input UserUpdateInput { name: String - email: String password: String tasks: TaskRelateToManyForUpdateInput } @@ -195,14 +191,13 @@ input TaskRelateToManyForUpdateInput { connect: [TaskWhereUniqueInput!] } -input PersonUpdateArgs { - where: PersonWhereUniqueInput! - data: PersonUpdateInput! +input UserUpdateArgs { + where: UserWhereUniqueInput! + data: UserUpdateInput! } -input PersonCreateInput { +input UserCreateInput { name: String - email: String password: String tasks: TaskRelateToManyForCreateInput } @@ -224,31 +219,30 @@ type Mutation { updateTasks(data: [TaskUpdateArgs!]!): [Task] deleteTask(where: TaskWhereUniqueInput!): Task deleteTasks(where: [TaskWhereUniqueInput!]!): [Task] - createPerson(data: PersonCreateInput!): Person - createPeople(data: [PersonCreateInput!]!): [Person] - updatePerson(where: PersonWhereUniqueInput!, data: PersonUpdateInput!): Person - updatePeople(data: [PersonUpdateArgs!]!): [Person] - deletePerson(where: PersonWhereUniqueInput!): Person - deletePeople(where: [PersonWhereUniqueInput!]!): [Person] + 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! - authenticatePersonWithPassword(email: String!, password: String!): PersonAuthenticationWithPasswordResult - createInitialPerson(data: CreateInitialPersonInput!): PersonAuthenticationWithPasswordSuccess! + authenticateUserWithPassword(name: String!, password: String!): UserAuthenticationWithPasswordResult + createInitialUser(data: CreateInitialUserInput!): UserAuthenticationWithPasswordSuccess! } -union PersonAuthenticationWithPasswordResult = PersonAuthenticationWithPasswordSuccess | PersonAuthenticationWithPasswordFailure +union UserAuthenticationWithPasswordResult = UserAuthenticationWithPasswordSuccess | UserAuthenticationWithPasswordFailure -type PersonAuthenticationWithPasswordSuccess { +type UserAuthenticationWithPasswordSuccess { sessionToken: String! - item: Person! + item: User! } -type PersonAuthenticationWithPasswordFailure { +type UserAuthenticationWithPasswordFailure { message: String! } -input CreateInitialPersonInput { +input CreateInitialUserInput { name: String - email: String password: String } @@ -256,14 +250,14 @@ type Query { tasks(where: TaskWhereInput! = {}, orderBy: [TaskOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: TaskWhereUniqueInput): [Task!] task(where: TaskWhereUniqueInput!): Task tasksCount(where: TaskWhereInput! = {}): Int - people(where: PersonWhereInput! = {}, orderBy: [PersonOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: PersonWhereUniqueInput): [Person!] - person(where: PersonWhereUniqueInput!): Person - peopleCount(where: PersonWhereInput! = {}): Int + users(where: UserWhereInput! = {}, orderBy: [UserOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: UserWhereUniqueInput): [User!] + user(where: UserWhereUniqueInput!): User + usersCount(where: UserWhereInput! = {}): Int keystone: KeystoneMeta! authenticatedItem: AuthenticatedItem } -union AuthenticatedItem = Person +union AuthenticatedItem = User type KeystoneMeta { adminMeta: KeystoneAdminMeta! diff --git a/examples/testing/schema.prisma b/examples/testing/schema.prisma index 01d35f09b57..b03b0233709 100644 --- a/examples/testing/schema.prisma +++ b/examples/testing/schema.prisma @@ -17,17 +17,16 @@ model Task { label String @default("") priority String? isComplete Boolean @default(false) - assignedTo Person? @relation("Task_assignedTo", fields: [assignedToId], references: [id]) + assignedTo User? @relation("Task_assignedTo", fields: [assignedToId], references: [id]) assignedToId String? @map("assignedTo") finishBy DateTime? @@index([assignedToId]) } -model Person { +model User { id String @id @default(cuid()) - name String @default("") - email String @unique @default("") + name String @unique @default("") password String tasks Task[] @relation("Task_assignedTo") } diff --git a/examples/testing/schema.ts b/examples/testing/schema.ts index b125cefb0a9..46f925228d5 100644 --- a/examples/testing/schema.ts +++ b/examples/testing/schema.ts @@ -2,23 +2,36 @@ import { list } from '@keystone-6/core'; import { checkbox, password, relationship, text, timestamp } from '@keystone-6/core/fields'; import { select } from '@keystone-6/core/fields'; import { allowAll } from '@keystone-6/core/access'; -import { Lists } from '.keystone/types'; +import type { Lists } from '.keystone/types'; -export const lists: Lists = { +// needs to be compatible with withAuth +export type Session = { + listKey: string; + itemId: string; + data: {}; +}; + +function isAssignedUserFilter({ session }: { session?: Session }) { + // you need to have a session + if (!session) return false; + + // the authenticated user can edit posts they are assigned to + return { + assignedTo: { + id: { + equals: session.itemId, + }, + }, + }; +} + +export const lists: Lists = { Task: list({ - // Add access control so that only the assigned user can update a task - // We will write a test to verify that this is working correctly. access: { - item: { - update: async ({ session, item, context }) => { - const task = await context.query.Task.findOne({ - where: { id: item.id.toString() }, - query: 'assignedTo { id }', - }); - return !!(session?.itemId && session.itemId === task.assignedTo?.id); - }, - }, operation: allowAll, + filter: { + update: isAssignedUserFilter, + }, }, fields: { label: text({ validation: { isRequired: true } }), @@ -31,15 +44,14 @@ export const lists: Lists = { ], }), isComplete: checkbox(), - assignedTo: relationship({ ref: 'Person.tasks', many: false }), + assignedTo: relationship({ ref: 'User.tasks', many: false }), finishBy: timestamp(), }, }), - Person: list({ + User: list({ access: allowAll, fields: { - name: text({ validation: { isRequired: true } }), - email: text({ isIndexed: 'unique', validation: { isRequired: true } }), + name: text({ isIndexed: 'unique', validation: { isRequired: true } }), password: password({ validation: { isRequired: true } }), tasks: relationship({ ref: 'Task.assignedTo', many: true }), }, diff --git a/examples/usecase-blog-moderated/keystone-example.db b/examples/usecase-blog-moderated/keystone-example.db new file mode 100644 index 00000000000..2a5a95ba5dc Binary files /dev/null and b/examples/usecase-blog-moderated/keystone-example.db differ diff --git a/examples/usecase-blog-moderated/keystone.ts b/examples/usecase-blog-moderated/keystone.ts new file mode 100644 index 00000000000..9e67ff37153 --- /dev/null +++ b/examples/usecase-blog-moderated/keystone.ts @@ -0,0 +1,52 @@ +import { config } from '@keystone-6/core'; +import { fixPrismaPath } from '../example-utils'; +import { lists, Session } from './schema'; +import type { Context, TypeInfo } from '.keystone/types'; + +const sillySessionStrategy = { + async get({ context }: { context: Context }): Promise { + if (!context.req) return; + + // WARNING: for demonstrative purposes only, this has no authentication + // use `Cookie:user=clh9v6pcn0000sbhm9u0j6in0` for Alice (admin) + // use `Cookie:user=clh9v762w0002sbhmhhyc0340` for Bob (moderator) + // use `Cookie:user=clh9v7ahs0004sbhmpx30w85n` for Eve (contributor) + // + // in practice, you should use authentication for your sessions, such as OAuth or JWT + const { cookie = '' } = context.req.headers; + const [user, id] = cookie.split('='); + if (user !== 'user') return; + + const who = await context.sudo().db.User.findOne({ where: { id } }); + if (!who) return; + return { + id, + admin: who.admin, + moderator: who.moderatorId ? { id: who.moderatorId } : null, + contributor: who.contributorId ? { id: who.contributorId } : null, + }; + }, + + // we don't need these unless we want to support the functions + // context.sessionStrategy.start + // context.sessionStrategy.end + // + async start(): Promise { + return; + }, + async end(): Promise { + return; + }, +}; + +export default 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 + ...fixPrismaPath, + }, + lists, + session: sillySessionStrategy, +}); diff --git a/examples/usecase-blog-moderated/package.json b/examples/usecase-blog-moderated/package.json new file mode 100644 index 00000000000..25013419f02 --- /dev/null +++ b/examples/usecase-blog-moderated/package.json @@ -0,0 +1,20 @@ +{ + "name": "@keystone-6/example-usecase-blog-moderated", + "private": true, + "license": "MIT", + "scripts": { + "dev": "keystone dev", + "start": "keystone start", + "build": "keystone build", + "postinstall": "keystone postinstall" + }, + "dependencies": { + "@keystone-6/auth": "^7.0.0", + "@keystone-6/core": "^5.0.0", + "@prisma/client": "^4.13.0" + }, + "devDependencies": { + "prisma": "^4.13.0", + "typescript": "~5.0.0" + } +} diff --git a/examples/usecase-blog-moderated/sandbox.config.json b/examples/usecase-blog-moderated/sandbox.config.json new file mode 100644 index 00000000000..7a34682ee45 --- /dev/null +++ b/examples/usecase-blog-moderated/sandbox.config.json @@ -0,0 +1,7 @@ +{ + "template": "node", + "container": { + "startScript": "keystone dev", + "node": "16" + } +} diff --git a/examples/usecase-blog-moderated/schema.graphql b/examples/usecase-blog-moderated/schema.graphql new file mode 100644 index 00000000000..31b1086239d --- /dev/null +++ b/examples/usecase-blog-moderated/schema.graphql @@ -0,0 +1,451 @@ +# This file is automatically generated by Keystone, do not modify it manually. +# Modify your Keystone config when you want to change this. + +type Post { + id: ID! + title: String + content: String + hidden: Boolean + createdBy: Contributor + createdAt: DateTime + updatedAt: DateTime + hiddenBy: Moderator + hiddenAt: DateTime +} + +scalar DateTime @specifiedBy(url: "https://datatracker.ietf.org/doc/html/rfc3339#section-5.6") + +input PostWhereUniqueInput { + id: ID +} + +input PostWhereInput { + AND: [PostWhereInput!] + OR: [PostWhereInput!] + NOT: [PostWhereInput!] + id: IDFilter + title: StringFilter + content: StringFilter + hidden: BooleanFilter + createdBy: ContributorWhereInput + createdAt: DateTimeNullableFilter + updatedAt: DateTimeNullableFilter + hiddenBy: ModeratorWhereInput + hiddenAt: DateTimeNullableFilter +} + +input IDFilter { + equals: ID + in: [ID!] + notIn: [ID!] + lt: ID + lte: ID + gt: ID + gte: ID + not: IDFilter +} + +input StringFilter { + equals: String + in: [String!] + notIn: [String!] + lt: String + lte: String + gt: String + gte: String + contains: String + startsWith: String + endsWith: String + not: NestedStringFilter +} + +input NestedStringFilter { + equals: String + in: [String!] + notIn: [String!] + lt: String + lte: String + gt: String + gte: String + contains: String + startsWith: String + endsWith: String + not: NestedStringFilter +} + +input BooleanFilter { + equals: Boolean + not: BooleanFilter +} + +input DateTimeNullableFilter { + equals: DateTime + in: [DateTime!] + notIn: [DateTime!] + lt: DateTime + lte: DateTime + gt: DateTime + gte: DateTime + not: DateTimeNullableFilter +} + +input PostOrderByInput { + id: OrderDirection + title: OrderDirection + content: OrderDirection + hidden: OrderDirection + createdAt: OrderDirection + updatedAt: OrderDirection + hiddenAt: OrderDirection +} + +enum OrderDirection { + asc + desc +} + +input PostUpdateInput { + title: String + content: String + hidden: Boolean + createdBy: ContributorRelateToOneForUpdateInput + createdAt: DateTime + updatedAt: DateTime + hiddenBy: ModeratorRelateToOneForUpdateInput + hiddenAt: DateTime +} + +input ContributorRelateToOneForUpdateInput { + create: ContributorCreateInput + connect: ContributorWhereUniqueInput + disconnect: Boolean +} + +input ModeratorRelateToOneForUpdateInput { + create: ModeratorCreateInput + connect: ModeratorWhereUniqueInput + disconnect: Boolean +} + +input PostUpdateArgs { + where: PostWhereUniqueInput! + data: PostUpdateInput! +} + +input PostCreateInput { + title: String + content: String + hidden: Boolean + createdBy: ContributorRelateToOneForCreateInput + createdAt: DateTime + updatedAt: DateTime + hiddenBy: ModeratorRelateToOneForCreateInput + hiddenAt: DateTime +} + +input ContributorRelateToOneForCreateInput { + create: ContributorCreateInput + connect: ContributorWhereUniqueInput +} + +input ModeratorRelateToOneForCreateInput { + create: ModeratorCreateInput + connect: ModeratorWhereUniqueInput +} + +type Contributor { + id: ID! + bio: String + posts(where: PostWhereInput! = {}, orderBy: [PostOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: PostWhereUniqueInput): [Post!] + postsCount(where: PostWhereInput! = {}): Int +} + +input ContributorWhereUniqueInput { + id: ID +} + +input ContributorWhereInput { + AND: [ContributorWhereInput!] + OR: [ContributorWhereInput!] + NOT: [ContributorWhereInput!] + id: IDFilter + bio: StringFilter + posts: PostManyRelationFilter +} + +input PostManyRelationFilter { + every: PostWhereInput + some: PostWhereInput + none: PostWhereInput +} + +input ContributorOrderByInput { + id: OrderDirection + bio: OrderDirection +} + +input ContributorUpdateInput { + bio: String + posts: PostRelateToManyForUpdateInput +} + +input PostRelateToManyForUpdateInput { + disconnect: [PostWhereUniqueInput!] + set: [PostWhereUniqueInput!] + create: [PostCreateInput!] + connect: [PostWhereUniqueInput!] +} + +input ContributorUpdateArgs { + where: ContributorWhereUniqueInput! + data: ContributorUpdateInput! +} + +input ContributorCreateInput { + bio: String + posts: PostRelateToManyForCreateInput +} + +input PostRelateToManyForCreateInput { + create: [PostCreateInput!] + connect: [PostWhereUniqueInput!] +} + +type Moderator { + id: ID! + hidden(where: PostWhereInput! = {}, orderBy: [PostOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: PostWhereUniqueInput): [Post!] + hiddenCount(where: PostWhereInput! = {}): Int +} + +input ModeratorWhereUniqueInput { + id: ID +} + +input ModeratorWhereInput { + AND: [ModeratorWhereInput!] + OR: [ModeratorWhereInput!] + NOT: [ModeratorWhereInput!] + id: IDFilter + hidden: PostManyRelationFilter +} + +input ModeratorOrderByInput { + id: OrderDirection +} + +input ModeratorUpdateInput { + hidden: PostRelateToManyForUpdateInput +} + +input ModeratorUpdateArgs { + where: ModeratorWhereUniqueInput! + data: ModeratorUpdateInput! +} + +input ModeratorCreateInput { + hidden: PostRelateToManyForCreateInput +} + +type User { + id: ID! + name: String + admin: Boolean + contributor: Contributor + moderator: Moderator +} + +input UserWhereUniqueInput { + id: ID +} + +input UserWhereInput { + AND: [UserWhereInput!] + OR: [UserWhereInput!] + NOT: [UserWhereInput!] + id: IDFilter + name: StringFilter + admin: BooleanFilter + contributor: ContributorWhereInput + moderator: ModeratorWhereInput +} + +input UserOrderByInput { + id: OrderDirection + name: OrderDirection + admin: OrderDirection +} + +input UserUpdateInput { + name: String + admin: Boolean + contributor: ContributorRelateToOneForUpdateInput + moderator: ModeratorRelateToOneForUpdateInput +} + +input UserUpdateArgs { + where: UserWhereUniqueInput! + data: UserUpdateInput! +} + +input UserCreateInput { + name: String + admin: Boolean + contributor: ContributorRelateToOneForCreateInput + moderator: ModeratorRelateToOneForCreateInput +} + +""" +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 { + createPost(data: PostCreateInput!): Post + createPosts(data: [PostCreateInput!]!): [Post] + updatePost(where: PostWhereUniqueInput!, data: PostUpdateInput!): Post + updatePosts(data: [PostUpdateArgs!]!): [Post] + deletePost(where: PostWhereUniqueInput!): Post + deletePosts(where: [PostWhereUniqueInput!]!): [Post] + createContributor(data: ContributorCreateInput!): Contributor + createContributors(data: [ContributorCreateInput!]!): [Contributor] + updateContributor(where: ContributorWhereUniqueInput!, data: ContributorUpdateInput!): Contributor + updateContributors(data: [ContributorUpdateArgs!]!): [Contributor] + deleteContributor(where: ContributorWhereUniqueInput!): Contributor + deleteContributors(where: [ContributorWhereUniqueInput!]!): [Contributor] + createModerator(data: ModeratorCreateInput!): Moderator + createModerators(data: [ModeratorCreateInput!]!): [Moderator] + updateModerator(where: ModeratorWhereUniqueInput!, data: ModeratorUpdateInput!): Moderator + updateModerators(data: [ModeratorUpdateArgs!]!): [Moderator] + deleteModerator(where: ModeratorWhereUniqueInput!): Moderator + deleteModerators(where: [ModeratorWhereUniqueInput!]!): [Moderator] + 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! +} + +type Query { + posts(where: PostWhereInput! = {}, orderBy: [PostOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: PostWhereUniqueInput): [Post!] + post(where: PostWhereUniqueInput!): Post + postsCount(where: PostWhereInput! = {}): Int + contributors(where: ContributorWhereInput! = {}, orderBy: [ContributorOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: ContributorWhereUniqueInput): [Contributor!] + contributor(where: ContributorWhereUniqueInput!): Contributor + contributorsCount(where: ContributorWhereInput! = {}): Int + moderators(where: ModeratorWhereInput! = {}, orderBy: [ModeratorOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: ModeratorWhereUniqueInput): [Moderator!] + moderator(where: ModeratorWhereUniqueInput!): Moderator + moderatorsCount(where: ModeratorWhereInput! = {}): Int + users(where: UserWhereInput! = {}, orderBy: [UserOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: UserWhereUniqueInput): [User!] + user(where: UserWhereUniqueInput!): User + usersCount(where: UserWhereInput! = {}): Int + keystone: KeystoneMeta! +} + +type KeystoneMeta { + adminMeta: KeystoneAdminMeta! +} + +type KeystoneAdminMeta { + lists: [KeystoneAdminUIListMeta!]! + list(key: String!): KeystoneAdminUIListMeta +} + +type KeystoneAdminUIListMeta { + key: String! + itemQueryName: String! + listQueryName: String! + hideCreate: Boolean! + hideDelete: Boolean! + path: String! + label: String! + singular: String! + plural: String! + description: String + initialColumns: [String!]! + pageSize: Int! + labelField: String! + fields: [KeystoneAdminUIFieldMeta!]! + groups: [KeystoneAdminUIFieldGroupMeta!]! + initialSort: KeystoneAdminUISort + isHidden: Boolean! + isSingleton: Boolean! +} + +type KeystoneAdminUIFieldMeta { + path: String! + label: String! + description: String + isOrderable: Boolean! + isFilterable: Boolean! + isNonNull: [KeystoneAdminUIFieldMetaIsNonNull!] + fieldMeta: JSON + viewsIndex: Int! + customViewsIndex: Int + createView: KeystoneAdminUIFieldMetaCreateView! + listView: KeystoneAdminUIFieldMetaListView! + itemView(id: ID): KeystoneAdminUIFieldMetaItemView + search: QueryMode +} + +enum KeystoneAdminUIFieldMetaIsNonNull { + read + create + update +} + +type KeystoneAdminUIFieldMetaCreateView { + fieldMode: KeystoneAdminUIFieldMetaCreateViewFieldMode! +} + +enum KeystoneAdminUIFieldMetaCreateViewFieldMode { + edit + hidden +} + +type KeystoneAdminUIFieldMetaListView { + fieldMode: KeystoneAdminUIFieldMetaListViewFieldMode! +} + +enum KeystoneAdminUIFieldMetaListViewFieldMode { + read + hidden +} + +type KeystoneAdminUIFieldMetaItemView { + fieldMode: KeystoneAdminUIFieldMetaItemViewFieldMode + fieldPosition: KeystoneAdminUIFieldMetaItemViewFieldPosition +} + +enum KeystoneAdminUIFieldMetaItemViewFieldMode { + edit + read + hidden +} + +enum KeystoneAdminUIFieldMetaItemViewFieldPosition { + form + sidebar +} + +enum QueryMode { + default + insensitive +} + +type KeystoneAdminUIFieldGroupMeta { + label: String! + description: String + fields: [KeystoneAdminUIFieldMeta!]! +} + +type KeystoneAdminUISort { + field: String! + direction: KeystoneAdminUISortDirection! +} + +enum KeystoneAdminUISortDirection { + ASC + DESC +} diff --git a/examples/usecase-blog-moderated/schema.prisma b/examples/usecase-blog-moderated/schema.prisma new file mode 100644 index 00000000000..89c75e6fef0 --- /dev/null +++ b/examples/usecase-blog-moderated/schema.prisma @@ -0,0 +1,56 @@ +// 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/client" +} + +model Post { + id String @id @default(cuid()) + title String @default("") + content String @default("") + hidden Boolean @default(false) + createdBy Contributor? @relation("Post_createdBy", fields: [createdById], references: [id]) + createdById String? @map("createdBy") + createdAt DateTime? + updatedAt DateTime? + hiddenBy Moderator? @relation("Post_hiddenBy", fields: [hiddenById], references: [id]) + hiddenById String? @map("hiddenBy") + hiddenAt DateTime? + + @@index([createdById]) + @@index([hiddenById]) +} + +model Contributor { + id String @id @default(cuid()) + bio String @default("") + posts Post[] @relation("Post_createdBy") + from_User_contributor User[] @relation("User_contributor") +} + +model Moderator { + id String @id @default(cuid()) + hidden Post[] @relation("Post_hiddenBy") + from_User_moderator User[] @relation("User_moderator") +} + +model User { + id String @id @default(cuid()) + name String @default("") + admin Boolean @default(false) + contributor Contributor? @relation("User_contributor", fields: [contributorId], references: [id]) + contributorId String? @map("contributor") + moderator Moderator? @relation("User_moderator", fields: [moderatorId], references: [id]) + moderatorId String? @map("moderator") + + @@index([contributorId]) + @@index([moderatorId]) +} diff --git a/examples/usecase-blog-moderated/schema.ts b/examples/usecase-blog-moderated/schema.ts new file mode 100644 index 00000000000..3eee7303bf4 --- /dev/null +++ b/examples/usecase-blog-moderated/schema.ts @@ -0,0 +1,264 @@ +import { list } from '@keystone-6/core'; +import { allowAll, denyAll, unfiltered } from '@keystone-6/core/access'; +import { checkbox, text, relationship, timestamp } from '@keystone-6/core/fields'; +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 = { + id: string; + admin: boolean; + contributor: null | { id: string }; + moderator: null | { id: string }; +}; + +function forUsers({ + admin, + moderator, + contributor, + default: _default, +}: { + admin?: ({ session }: { session: Session }) => T; + moderator?: ({ session }: { session: Session }) => T; + contributor?: ({ session }: { session: Session }) => T; + default: () => T; +}) { + return ({ session }: { session?: Session }): T => { + if (!session) return _default(); + if (admin && session.admin) return admin({ session }); + if (moderator && session.moderator) return moderator({ session }); + if (contributor && session.contributor) return contributor({ session }); + return _default(); + }; +} + +const adminOnly = forUsers({ + admin: allowAll, + default: denyAll, +}); + +const moderatorsOrAbove = forUsers({ + admin: allowAll, + moderator: allowAll, + default: denyAll, +}); + +const contributorsOrAbove = forUsers({ + admin: allowAll, + moderator: allowAll, + contributor: allowAll, + default: denyAll, +}); + +function readOnlyBy(f: ({ session }: { session?: Session }) => boolean) { + return { + read: f, + create: denyAll, + update: denyAll, + }; +} + +function viewOnlyBy(f: ({ session }: { session?: Session }) => boolean, mode: 'edit' | 'read') { + return { + createView: { + fieldMode: ({ session }: { session?: Session }) => + f({ session }) ? (mode === 'edit' ? 'edit' : 'hidden') : 'hidden', + }, + itemView: { + fieldMode: ({ session }: { session?: Session }) => (f({ session }) ? mode : 'hidden'), + }, + listView: { + fieldMode: ({ session }: { session?: Session }) => (f({ session }) ? 'read' : 'hidden'), + }, + }; +} + +function readOnlyViewBy(f: ({ session }: { session?: Session }) => boolean) { + return viewOnlyBy(f, 'read'); +} + +function editOnlyViewBy(f: ({ session }: { session?: Session }) => boolean) { + return viewOnlyBy(f, 'edit'); +} + +export const lists: Lists = { + Post: list({ + access: { + operation: { + query: allowAll, // WARNING: public + create: forUsers({ + admin: allowAll, + contributor: allowAll, + default: denyAll, + }), + update: contributorsOrAbove, + delete: adminOnly, + }, + filter: { + // the 'default' allowed query is non-hidden posts + // but admins and moderators can see everything + query: forUsers({ + admin: unfiltered, + moderator: unfiltered, + default: () => ({ + hiddenBy: null, + }), + }), + update: forUsers({ + // contributors can only update their own posts + contributor: ({ session }) => ({ + createdBy: { id: { equals: session.contributor?.id } }, + hiddenBy: null, + }), + // otherwise, no filter + default: unfiltered, + }), + }, + }, + fields: { + title: text(), + content: text(), + hidden: checkbox({ + access: moderatorsOrAbove, + ui: { + ...editOnlyViewBy(moderatorsOrAbove), + }, + }), + + // read only fields + createdBy: relationship({ + ref: 'Contributor.posts', + access: readOnlyBy(allowAll), + ui: { + ...readOnlyViewBy(allowAll), + }, + }), + createdAt: timestamp({ + access: readOnlyBy(allowAll), + ui: { + ...readOnlyViewBy(allowAll), + }, + }), + updatedAt: timestamp({ + access: readOnlyBy(allowAll), + ui: { + ...readOnlyViewBy(allowAll), + }, + }), + hiddenBy: relationship({ + ref: 'Moderator.hidden', + access: readOnlyBy(moderatorsOrAbove), + ui: { + ...readOnlyViewBy(moderatorsOrAbove), + }, + }), + hiddenAt: timestamp({ + access: readOnlyBy(moderatorsOrAbove), + ui: { + ...readOnlyViewBy(moderatorsOrAbove), + }, + }), + }, + hooks: { + resolveInput: { + create: ({ context, resolvedData }) => { + resolvedData.createdAt = new Date(); + if (context.session?.contributor) { + return { + ...resolvedData, + createdBy: { + connect: { + id: context.session?.contributor?.id, + }, + }, + }; + } + return resolvedData; + }, + update: ({ context, resolvedData }) => { + resolvedData.updatedAt = new Date(); + if ('hidden' in resolvedData && context.session?.moderator) { + resolvedData.hiddenBy = resolvedData.hidden + ? { + connect: { + id: context.session?.moderator?.id, + }, + // TODO: should support : null + } + : { + disconnect: true, + }; + + resolvedData.hiddenAt = resolvedData.hidden ? new Date() : null; + } + + return resolvedData; + }, + }, + }, + }), + + Contributor: list({ + access: { + operation: { + query: allowAll, // WARNING: public + create: adminOnly, + update: contributorsOrAbove, + delete: adminOnly, + }, + filter: { + // contributors can only update themselves + update: forUsers({ + contributor: ({ session }) => ({ id: { equals: session.contributor?.id } }), + default: unfiltered, + }), + }, + }, + fields: { + bio: text(), + posts: relationship({ + ref: 'Post.createdBy', + access: readOnlyBy(allowAll), // WARNING: usually you want this to be the same as Posts.createdBy + many: true, + }), + }, + }), + + Moderator: list({ + access: { + operation: { + query: moderatorsOrAbove, + create: adminOnly, + update: moderatorsOrAbove, + delete: adminOnly, + }, + filter: { + // moderators can only update themselves + update: forUsers({ + moderator: ({ session }) => ({ id: { equals: session.moderator?.id } }), + // otherwise, no filter + default: unfiltered, + }), + }, + }, + fields: { + hidden: relationship({ + ref: 'Post.hiddenBy', + access: readOnlyBy(allowAll), // WARNING: usually you want this to be the same as Posts.hiddenBy + many: true, + }), + }, + }), + + User: list({ + access: adminOnly, + fields: { + name: text(), + admin: checkbox(), + contributor: relationship({ ref: 'Contributor' }), + moderator: relationship({ ref: 'Moderator' }), + }, + }), +}; diff --git a/examples/usecase-blog/package.json b/examples/usecase-blog/package.json index 709e3f6a879..f8b0397a0dc 100644 --- a/examples/usecase-blog/package.json +++ b/examples/usecase-blog/package.json @@ -19,6 +19,5 @@ "prisma": "^4.14.0", "tsx": "^3.9.0", "typescript": "~5.0.0" - }, - "repository": "https://github.com/keystonejs/keystone/tree/main/examples/usecase-blog" + } } diff --git a/examples/usecase-roles/access.ts b/examples/usecase-roles/access.ts index cf8204cab84..93a9dc0ac22 100644 --- a/examples/usecase-roles/access.ts +++ b/examples/usecase-roles/access.ts @@ -1,4 +1,25 @@ -import { ListAccessArgs } from './types'; +export type Session = { + itemId: string; + listKey: string; + data: { + name: string; + role: { + id: string; + name: string; + canCreateTodos: boolean; + canManageAllTodos: boolean; + canSeeOtherPeople: boolean; + canEditOtherPeople: boolean; + canManagePeople: boolean; + canManageRoles: boolean; + canUseAdminUI: boolean; + }; + }; +}; + +type ListAccessArgs = { + session?: Session; +}; /* The basic level of access to the system is being signed in as a valid user. This gives you access diff --git a/examples/usecase-roles/keystone.ts b/examples/usecase-roles/keystone.ts index 0b468ff6b64..f81345606cb 100644 --- a/examples/usecase-roles/keystone.ts +++ b/examples/usecase-roles/keystone.ts @@ -4,21 +4,39 @@ import { createAuth } from '@keystone-6/auth'; import { fixPrismaPath } from '../example-utils'; import { lists } from './schema'; -import { isSignedIn } from './access'; +// 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 --'; -const sessionMaxAge = 60 * 60 * 24 * 30; // 30 days -const sessionConfig = { - maxAge: sessionMaxAge, - secret: sessionSecret, -}; +// statelessSessions uses cookies for session tracking +// these cookies have an expiry, in seconds +// we use an expiry of 30 days for this example +const sessionMaxAge = 60 * 60 * 24 * 30; + +// withAuth is a function we can use to wrap our base configuration const { withAuth } = createAuth({ - listKey: 'Person', - identityField: 'email', + // 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: { - fields: ['name', 'email', 'password'], + // 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: { /* This creates a related role with full permissions, so that when the first user signs in @@ -33,13 +51,15 @@ const { withAuth } = createAuth({ canEditOtherPeople: true, canManagePeople: true, canManageRoles: true, + canUseAdminUI: true, }, }, }, }, - /* This loads the related role for the current user, including all permissions */ + sessionData: ` - name role { + name + role { id name canCreateTodos @@ -48,6 +68,7 @@ const { withAuth } = createAuth({ canEditOtherPeople canManagePeople canManageRoles + canUseAdminUI }`, }); @@ -62,12 +83,16 @@ export default withAuth( }, lists, ui: { - // TODO: isSignedIn is the default, a better example would be limiting users - // isAccessAllowed: canViewAdminUI, - - /* Everyone who is signed in can access the Admin UI */ - isAccessAllowed: isSignedIn, + isAccessAllowed: ({ session }) => { + return session?.data.role?.canUseAdminUI ?? false; + }, }, - session: statelessSessions(sessionConfig), + // you can find out more at https://keystonejs.com/docs/apis/session#session-api + session: 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, + }), }) ); diff --git a/examples/usecase-roles/package.json b/examples/usecase-roles/package.json index 9941d72b15c..b460fdaa20f 100644 --- a/examples/usecase-roles/package.json +++ b/examples/usecase-roles/package.json @@ -1,5 +1,5 @@ { - "name": "@keystone-6/example-roles", + "name": "@keystone-6/example-usecase-roles", "version": "0.1.1", "private": true, "license": "MIT", @@ -17,6 +17,5 @@ "devDependencies": { "prisma": "^4.14.0", "typescript": "~5.0.0" - }, - "repository": "https://github.com/keystonejs/keystone/tree/main/examples/usecase-roles" + } } diff --git a/examples/usecase-roles/schema.graphql b/examples/usecase-roles/schema.graphql index 53b7a03af3a..fd2f1122126 100644 --- a/examples/usecase-roles/schema.graphql +++ b/examples/usecase-roles/schema.graphql @@ -6,7 +6,7 @@ type Todo { label: String isComplete: Boolean isPrivate: Boolean - assignedTo: Person + assignedTo: User } input TodoWhereUniqueInput { @@ -21,7 +21,7 @@ input TodoWhereInput { label: StringFilter isComplete: BooleanFilter isPrivate: BooleanFilter - assignedTo: PersonWhereInput + assignedTo: UserWhereInput } input IDFilter { @@ -84,12 +84,12 @@ input TodoUpdateInput { label: String isComplete: Boolean isPrivate: Boolean - assignedTo: PersonRelateToOneForUpdateInput + assignedTo: UserRelateToOneForUpdateInput } -input PersonRelateToOneForUpdateInput { - create: PersonCreateInput - connect: PersonWhereUniqueInput +input UserRelateToOneForUpdateInput { + create: UserCreateInput + connect: UserWhereUniqueInput disconnect: Boolean } @@ -102,18 +102,17 @@ input TodoCreateInput { label: String isComplete: Boolean isPrivate: Boolean - assignedTo: PersonRelateToOneForCreateInput + assignedTo: UserRelateToOneForCreateInput } -input PersonRelateToOneForCreateInput { - create: PersonCreateInput - connect: PersonWhereUniqueInput +input UserRelateToOneForCreateInput { + create: UserCreateInput + connect: UserWhereUniqueInput } -type Person { +type User { id: ID! name: String - email: String password: PasswordState role: Role tasks(where: TodoWhereInput! = {}, orderBy: [TodoOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: TodoWhereUniqueInput): [Todo!] @@ -124,18 +123,15 @@ type PasswordState { isSet: Boolean! } -input PersonWhereUniqueInput { +input UserWhereUniqueInput { id: ID - email: String } -input PersonWhereInput { - AND: [PersonWhereInput!] - OR: [PersonWhereInput!] - NOT: [PersonWhereInput!] +input UserWhereInput { + AND: [UserWhereInput!] + OR: [UserWhereInput!] + NOT: [UserWhereInput!] id: IDFilter - name: StringFilter - email: StringFilter role: RoleWhereInput tasks: TodoManyRelationFilter } @@ -146,15 +142,12 @@ input TodoManyRelationFilter { none: TodoWhereInput } -input PersonOrderByInput { +input UserOrderByInput { id: OrderDirection - name: OrderDirection - email: OrderDirection } -input PersonUpdateInput { +input UserUpdateInput { name: String - email: String password: String role: RoleRelateToOneForUpdateInput tasks: TodoRelateToManyForUpdateInput @@ -173,14 +166,13 @@ input TodoRelateToManyForUpdateInput { connect: [TodoWhereUniqueInput!] } -input PersonUpdateArgs { - where: PersonWhereUniqueInput! - data: PersonUpdateInput! +input UserUpdateArgs { + where: UserWhereUniqueInput! + data: UserUpdateInput! } -input PersonCreateInput { +input UserCreateInput { name: String - email: String password: String role: RoleRelateToOneForCreateInput tasks: TodoRelateToManyForCreateInput @@ -205,8 +197,9 @@ type Role { canEditOtherPeople: Boolean canManagePeople: Boolean canManageRoles: Boolean - assignedTo(where: PersonWhereInput! = {}, orderBy: [PersonOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: PersonWhereUniqueInput): [Person!] - assignedToCount(where: PersonWhereInput! = {}): Int + canUseAdminUI: Boolean + assignedTo(where: UserWhereInput! = {}, orderBy: [UserOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: UserWhereUniqueInput): [User!] + assignedToCount(where: UserWhereInput! = {}): Int } input RoleWhereUniqueInput { @@ -225,13 +218,14 @@ input RoleWhereInput { canEditOtherPeople: BooleanFilter canManagePeople: BooleanFilter canManageRoles: BooleanFilter - assignedTo: PersonManyRelationFilter + canUseAdminUI: BooleanFilter + assignedTo: UserManyRelationFilter } -input PersonManyRelationFilter { - every: PersonWhereInput - some: PersonWhereInput - none: PersonWhereInput +input UserManyRelationFilter { + every: UserWhereInput + some: UserWhereInput + none: UserWhereInput } input RoleOrderByInput { @@ -243,6 +237,7 @@ input RoleOrderByInput { canEditOtherPeople: OrderDirection canManagePeople: OrderDirection canManageRoles: OrderDirection + canUseAdminUI: OrderDirection } input RoleUpdateInput { @@ -253,14 +248,15 @@ input RoleUpdateInput { canEditOtherPeople: Boolean canManagePeople: Boolean canManageRoles: Boolean - assignedTo: PersonRelateToManyForUpdateInput + canUseAdminUI: Boolean + assignedTo: UserRelateToManyForUpdateInput } -input PersonRelateToManyForUpdateInput { - disconnect: [PersonWhereUniqueInput!] - set: [PersonWhereUniqueInput!] - create: [PersonCreateInput!] - connect: [PersonWhereUniqueInput!] +input UserRelateToManyForUpdateInput { + disconnect: [UserWhereUniqueInput!] + set: [UserWhereUniqueInput!] + create: [UserCreateInput!] + connect: [UserWhereUniqueInput!] } input RoleUpdateArgs { @@ -276,12 +272,13 @@ input RoleCreateInput { canEditOtherPeople: Boolean canManagePeople: Boolean canManageRoles: Boolean - assignedTo: PersonRelateToManyForCreateInput + canUseAdminUI: Boolean + assignedTo: UserRelateToManyForCreateInput } -input PersonRelateToManyForCreateInput { - create: [PersonCreateInput!] - connect: [PersonWhereUniqueInput!] +input UserRelateToManyForCreateInput { + create: [UserCreateInput!] + connect: [UserWhereUniqueInput!] } """ @@ -296,12 +293,12 @@ type Mutation { updateTodos(data: [TodoUpdateArgs!]!): [Todo] deleteTodo(where: TodoWhereUniqueInput!): Todo deleteTodos(where: [TodoWhereUniqueInput!]!): [Todo] - createPerson(data: PersonCreateInput!): Person - createPeople(data: [PersonCreateInput!]!): [Person] - updatePerson(where: PersonWhereUniqueInput!, data: PersonUpdateInput!): Person - updatePeople(data: [PersonUpdateArgs!]!): [Person] - deletePerson(where: PersonWhereUniqueInput!): Person - deletePeople(where: [PersonWhereUniqueInput!]!): [Person] + 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] createRole(data: RoleCreateInput!): Role createRoles(data: [RoleCreateInput!]!): [Role] updateRole(where: RoleWhereUniqueInput!, data: RoleUpdateInput!): Role @@ -309,24 +306,23 @@ type Mutation { deleteRole(where: RoleWhereUniqueInput!): Role deleteRoles(where: [RoleWhereUniqueInput!]!): [Role] endSession: Boolean! - authenticatePersonWithPassword(email: String!, password: String!): PersonAuthenticationWithPasswordResult - createInitialPerson(data: CreateInitialPersonInput!): PersonAuthenticationWithPasswordSuccess! + authenticateUserWithPassword(name: String!, password: String!): UserAuthenticationWithPasswordResult + createInitialUser(data: CreateInitialUserInput!): UserAuthenticationWithPasswordSuccess! } -union PersonAuthenticationWithPasswordResult = PersonAuthenticationWithPasswordSuccess | PersonAuthenticationWithPasswordFailure +union UserAuthenticationWithPasswordResult = UserAuthenticationWithPasswordSuccess | UserAuthenticationWithPasswordFailure -type PersonAuthenticationWithPasswordSuccess { +type UserAuthenticationWithPasswordSuccess { sessionToken: String! - item: Person! + item: User! } -type PersonAuthenticationWithPasswordFailure { +type UserAuthenticationWithPasswordFailure { message: String! } -input CreateInitialPersonInput { +input CreateInitialUserInput { name: String - email: String password: String } @@ -334,9 +330,9 @@ type Query { todos(where: TodoWhereInput! = {}, orderBy: [TodoOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: TodoWhereUniqueInput): [Todo!] todo(where: TodoWhereUniqueInput!): Todo todosCount(where: TodoWhereInput! = {}): Int - people(where: PersonWhereInput! = {}, orderBy: [PersonOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: PersonWhereUniqueInput): [Person!] - person(where: PersonWhereUniqueInput!): Person - peopleCount(where: PersonWhereInput! = {}): Int + users(where: UserWhereInput! = {}, orderBy: [UserOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: UserWhereUniqueInput): [User!] + user(where: UserWhereUniqueInput!): User + usersCount(where: UserWhereInput! = {}): Int roles(where: RoleWhereInput! = {}, orderBy: [RoleOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: RoleWhereUniqueInput): [Role!] role(where: RoleWhereUniqueInput!): Role rolesCount(where: RoleWhereInput! = {}): Int @@ -344,7 +340,7 @@ type Query { authenticatedItem: AuthenticatedItem } -union AuthenticatedItem = Person +union AuthenticatedItem = User type KeystoneMeta { adminMeta: KeystoneAdminMeta! diff --git a/examples/usecase-roles/schema.prisma b/examples/usecase-roles/schema.prisma index acff71ede5b..372925a0002 100644 --- a/examples/usecase-roles/schema.prisma +++ b/examples/usecase-roles/schema.prisma @@ -17,18 +17,17 @@ model Todo { label String @default("") isComplete Boolean @default(false) isPrivate Boolean @default(false) - assignedTo Person? @relation("Todo_assignedTo", fields: [assignedToId], references: [id]) + assignedTo User? @relation("Todo_assignedTo", fields: [assignedToId], references: [id]) assignedToId String? @map("assignedTo") @@index([assignedToId]) } -model Person { +model User { id String @id @default(cuid()) - name String @default("") - email String @unique @default("") + name String @unique @default("") password String - role Role? @relation("Person_role", fields: [roleId], references: [id]) + role Role? @relation("User_role", fields: [roleId], references: [id]) roleId String? @map("role") tasks Todo[] @relation("Todo_assignedTo") @@ -36,13 +35,14 @@ model Person { } model Role { - id String @id @default(cuid()) - name String @default("") - canCreateTodos Boolean @default(false) - canManageAllTodos Boolean @default(false) - canSeeOtherPeople Boolean @default(false) - canEditOtherPeople Boolean @default(false) - canManagePeople Boolean @default(false) - canManageRoles Boolean @default(false) - assignedTo Person[] @relation("Person_role") + id String @id @default(cuid()) + name String @default("") + canCreateTodos Boolean @default(false) + canManageAllTodos Boolean @default(false) + canSeeOtherPeople Boolean @default(false) + canEditOtherPeople Boolean @default(false) + canManagePeople Boolean @default(false) + canManageRoles Boolean @default(false) + canUseAdminUI Boolean @default(false) + assignedTo User[] @relation("User_role") } diff --git a/examples/usecase-roles/schema.ts b/examples/usecase-roles/schema.ts index d7c01fbe885..27aaf1ee933 100644 --- a/examples/usecase-roles/schema.ts +++ b/examples/usecase-roles/schema.ts @@ -1,5 +1,5 @@ import { list } from '@keystone-6/core'; -import { allOperations } from '@keystone-6/core/access'; +import { allOperations, denyAll } from '@keystone-6/core/access'; import { checkbox, password, relationship, text } from '@keystone-6/core/fields'; import { isSignedIn, permissions, rules } from './access'; @@ -57,7 +57,7 @@ export const lists = { isPrivate: checkbox({ defaultValue: false }), /* The person the todo item is assigned to */ assignedTo: relationship({ - ref: 'Person.tasks', + ref: 'User.tasks', ui: { createView: { fieldMode: args => (permissions.canManageAllTodos(args) ? 'edit' : 'hidden'), @@ -79,7 +79,7 @@ export const lists = { }), }, }), - Person: list({ + User: list({ /* SPEC - [x] Block all public access @@ -120,17 +120,29 @@ export const lists = { }, }, fields: { - /* The name of the user */ - name: text({ validation: { isRequired: true } }), - /* The email of the user, used to sign in */ - email: text({ isIndexed: 'unique', validation: { isRequired: true } }), - /* The password of the user */ + // 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 suitable for your application + name: text({ + 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({ validation: { isRequired: true }, access: { + read: denyAll, // TODO: is this required? update: ({ session, item }) => permissions.canManagePeople({ session }) || session.itemId === item.id, }, + // TODO: is anything else required }), /* The role assigned to the user */ role: relationship({ @@ -219,9 +231,12 @@ export const lists = { /* Manage Roles means: - create, edit, and delete roles */ canManageRoles: checkbox({ defaultValue: false }), + /* Use AdminUI means: + - can access the Admin UI next app */ + canUseAdminUI: checkbox({ defaultValue: false }), /* This list of People assigned to this role */ assignedTo: relationship({ - ref: 'Person.role', + ref: 'User.role', many: true, ui: { itemView: { fieldMode: 'read' }, diff --git a/examples/usecase-roles/types.ts b/examples/usecase-roles/types.ts deleted file mode 100644 index 95d9cff9872..00000000000 --- a/examples/usecase-roles/types.ts +++ /dev/null @@ -1,22 +0,0 @@ -export type Session = { - itemId: string; - listKey: string; - data: { - name: string; - role?: { - id: string; - name: string; - canCreateTodos: boolean; - canManageAllTodos: boolean; - canSeeOtherPeople: boolean; - canEditOtherPeople: boolean; - canManagePeople: boolean; - canManageRoles: boolean; - }; - }; -}; - -export type ListAccessArgs = { - itemId?: string; - session?: Session; -}; diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 84be0564b6d..4a8a86338f8 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -13,6 +13,13 @@ import { getSchemaExtension } from './schema'; import { signinTemplate } from './templates/signin'; import { initTemplate } from './templates/init'; +export type AuthSession = { + listKey: string; // TODO: use ListTypeInfo + itemId: string | number; // TODO: use ListTypeInfo + data: unknown; // TODO: use ListTypeInfo +}; + +// TODO: use TypeInfo and listKey for types /** * createAuth function * @@ -166,14 +173,11 @@ export function createAuth({ } }; - /** - * withItemData - * - * Automatically injects a session.data value with the authenticated item - */ - const withItemData = ( - _sessionStrategy: SessionStrategy> - ): SessionStrategy<{ listKey: string; itemId: string; data: any }> => { + // this strategy wraps the existing session strategy, + // and injects the requested session.data before returning + function authSessionStrategy( + _sessionStrategy: SessionStrategy + ): SessionStrategy { const { get, ...sessionStrategy } = _sessionStrategy; return { ...sessionStrategy, @@ -192,7 +196,7 @@ export function createAuth({ try { const data = await sudoContext.query[listKey].findOne({ - where: { id: session.itemId }, + where: { id: session.itemId as any }, // TODO: fix this query: sessionData, }); if (!data) return; @@ -205,7 +209,7 @@ export function createAuth({ } }, }; - }; + } async function hasInitFirstItemConditions(context: KeystoneContext) { // do nothing if they aren't using this feature @@ -301,7 +305,7 @@ export function createAuth({ return { ...keystoneConfig, ui, - session: withItemData(keystoneConfig.session), + session: authSessionStrategy(keystoneConfig.session), lists: { ...keystoneConfig.lists, [listKey]: { ...listConfig, fields: { ...listConfig.fields, ...fields } }, diff --git a/packages/core/src/lib/context/createContext.ts b/packages/core/src/lib/context/createContext.ts index 8c37dd0b4f7..3f50321b3d7 100644 --- a/packages/core/src/lib/context/createContext.ts +++ b/packages/core/src/lib/context/createContext.ts @@ -52,8 +52,8 @@ export function createContext({ sudo?: Boolean; req?: IncomingMessage; res?: ServerResponse; - session?: any; - } = {}): KeystoneContext => { + session?: unknown; + } = {}) => { const schema = sudo ? graphQLSchemaSudo : graphQLSchema; const rawGraphQL: KeystoneGraphQLAPI['raw'] = ({ query, variables }) => { const source = typeof query === 'string' ? query : print(query); @@ -97,7 +97,7 @@ export function createContext({ req, res, sessionStrategy: config.session, - session, + ...(session ? { session } : {}), withRequest, withSession: session => { diff --git a/packages/core/src/lib/schema-type-printer.tsx b/packages/core/src/lib/schema-type-printer.tsx index a32bff32136..18a610c4c88 100644 --- a/packages/core/src/lib/schema-type-printer.tsx +++ b/packages/core/src/lib/schema-type-printer.tsx @@ -188,10 +188,10 @@ function printListTypeInfo( // prettier-ignore return [ - `export type ${listKey} = import('@keystone-6/core').ListConfig<${listTypeInfoName}, any>;`, + `export type ${listKey} = import('@keystone-6/core').ListConfig<${listTypeInfoName}, any>;`, `namespace ${listKey} {`, ` export type Item = import('${prismaClientPath}').${listKey};`, - ` export type TypeInfo = {`, + ` export type TypeInfo = {`, ` key: '${listKey}';`, ` isSingleton: ${list.isSingleton};`, ` fields: ${Object.keys(list.fields).map(x => `'${x}'`).join(' | ')}`, @@ -207,7 +207,7 @@ function printListTypeInfo( ` create: ${list.graphql.isEnabled.create ? `Resolved${createInputName}` : 'never'};`, ` update: ${list.graphql.isEnabled.update ? `Resolved${updateInputName}` : 'never'};`, ` };`, - ` all: __TypeInfo;`, + ` all: __TypeInfo;`, ` };`, `}`, ] @@ -274,20 +274,22 @@ export function printGeneratedTypes( 'export declare namespace Lists {', ...listsNamespaces, '}', - `export type Context = import('@keystone-6/core/types').KeystoneContext;`, + `export type Context = import('@keystone-6/core/types').KeystoneContext>;`, + `export type Config = import('@keystone-6/core/types').KeystoneConfig>;`, '', - 'export type TypeInfo = {', + 'export type TypeInfo = {', ` lists: {`, ...listsTypeInfo, ` };`, ` prisma: import('${prismaClientPath}').PrismaClient;`, + ` session: Session;`, `};`, ``, // we need to reference the `TypeInfo` above in another type that is also called `TypeInfo` - `type __TypeInfo = TypeInfo;`, + `type __TypeInfo = TypeInfo;`, ``, - `export type Lists = {`, - ` [Key in keyof TypeInfo['lists']]?: import('@keystone-6/core').ListConfig`, + `export type Lists = {`, + ` [Key in keyof TypeInfo['lists']]?: import('@keystone-6/core').ListConfig['lists'][Key], any>`, `} & Record>;`, ``, `export {}`, diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index e5cc1bce4ac..ba9a3d06cf1 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -33,7 +33,7 @@ export function group< export function list< Fields extends BaseFields, - ListTypeInfo extends BaseListTypeInfo ->(config: ListConfig): ListConfig { + ListTypeInfo extends BaseListTypeInfo // TODO: remove in breaking change +>(config: ListConfig): ListConfig { return { ...config }; } diff --git a/packages/core/src/session/index.ts b/packages/core/src/session/index.ts index c04dd31285a..a77bade80f2 100644 --- a/packages/core/src/session/index.ts +++ b/packages/core/src/session/index.ts @@ -1,15 +1,7 @@ import * as cookie from 'cookie'; import Iron from '@hapi/iron'; -// uid-safe is what express-session uses so let's just use it import { sync as uid } from 'uid-safe'; -import { SessionStrategy, JSONValue, SessionStoreFunction } from '../types'; - -function generateSessionId() { - return uid(24); -} - -const TOKEN_NAME = 'keystonejs-session'; -const MAX_AGE = 60 * 60 * 8; // 8 hours +import type { SessionStrategy, SessionStoreFunction } from '../types'; // should we also accept httpOnly? type StatelessSessionsOptions = { @@ -31,6 +23,12 @@ type StatelessSessionsOptions = { * @default 60 * 60 * 8 // 8 hours */ maxAge?: number; + /** + * The name of the cookie used by `Set-Cookie`. + * + * @default keystonejs-session + */ + cookieName?: string; /** * Specifies the boolean value for the [`Secure` `Set-Cookie` attribute](https://tools.ietf.org/html/rfc6265#section-5.2.5). * @@ -61,15 +59,18 @@ type StatelessSessionsOptions = { sameSite?: true | false | 'lax' | 'strict' | 'none'; }; -export function statelessSessions({ +const MAX_AGE = 60 * 60 * 8; // 8 hours + +export function statelessSessions({ secret, maxAge = MAX_AGE, + cookieName = 'keystonejs-session', path = '/', secure = process.env.NODE_ENV === 'production', ironOptions = Iron.defaults, domain, sameSite = 'lax', -}: StatelessSessionsOptions): SessionStrategy { +}: StatelessSessionsOptions): SessionStrategy { if (!secret) { throw new Error('You must specify a session secret to use sessions'); } @@ -78,12 +79,11 @@ export function statelessSessions({ } return { async get({ context }) { - if (!context?.req) { - return; - } + if (!context?.req) return; + const cookies = cookie.parse(context.req.headers.cookie || ''); const bearer = context.req.headers.authorization?.replace('Bearer ', ''); - const token = bearer || cookies[TOKEN_NAME]; + const token = bearer || cookies[cookieName]; if (!token) return; try { return await Iron.unseal(token, secret, ironOptions); @@ -91,9 +91,10 @@ export function statelessSessions({ }, async end({ context }) { if (!context?.res) return; + context.res.setHeader( 'Set-Cookie', - cookie.serialize(TOKEN_NAME, '', { + cookie.serialize(cookieName, '', { maxAge: 0, expires: new Date(), httpOnly: true, @@ -106,11 +107,11 @@ export function statelessSessions({ }, async start({ context, data }) { if (!context?.res) return; - const sealedData = await Iron.seal(data, secret, { ...ironOptions, ttl: maxAge * 1000 }); + const sealedData = await Iron.seal(data, secret, { ...ironOptions, ttl: maxAge * 1000 }); context.res.setHeader( 'Set-Cookie', - cookie.serialize(TOKEN_NAME, sealedData, { + cookie.serialize(cookieName, sealedData, { maxAge, expires: new Date(Date.now() + maxAge * 1000), httpOnly: true, @@ -126,33 +127,35 @@ export function statelessSessions({ }; } -export function storedSessions({ - store: storeOption, +/** @deprecated */ +export function storedSessions({ + store: storeFn, maxAge = MAX_AGE, ...statelessSessionsOptions -}: { store: SessionStoreFunction } & StatelessSessionsOptions): SessionStrategy { - let { get, start, end } = statelessSessions({ ...statelessSessionsOptions, maxAge }); - let store = storeOption({ maxAge }); +}: { + store: SessionStoreFunction; +} & StatelessSessionsOptions): SessionStrategy { + const stateless = statelessSessions({ ...statelessSessionsOptions, maxAge }); + const store = storeFn({ maxAge }); + return { async get({ context }) { - const data = (await get({ context })) as { sessionId: string } | undefined; - const sessionId = data?.sessionId; - if (typeof sessionId === 'string') { - return store.get(sessionId); - } + const sessionId = await stateless.get({ context }); + if (!sessionId) return; + + return store.get(sessionId); }, - async start({ data, context }) { - let sessionId = generateSessionId(); + async start({ context, data }) { + const sessionId = uid(24); await store.set(sessionId, data); - return start?.({ data: { sessionId }, context }) || ''; + return stateless.start({ context, data: sessionId }) || ''; }, async end({ context }) { - const data = (await get({ context })) as { sessionId: string } | undefined; - const sessionId = data?.sessionId; - if (typeof sessionId === 'string') { - await store.delete(sessionId); - } - await end?.({ context }); + const sessionId = await stateless.get({ context }); + if (!sessionId) return; + + await store.delete(sessionId); + await stateless.end({ context }); }, }; } diff --git a/packages/core/src/types/config/access-control.ts b/packages/core/src/types/config/access-control.ts index db24719cf63..1b65977d3f8 100644 --- a/packages/core/src/types/config/access-control.ts +++ b/packages/core/src/types/config/access-control.ts @@ -1,11 +1,11 @@ import type { MaybePromise } from '../utils'; -import type { KeystoneContextFromListTypeInfo } from '..'; +import type { KeystoneContext } from '../context'; import type { BaseListTypeInfo } from '../type-info'; export type BaseAccessArgs = { - session: any; + context: KeystoneContext; + session?: ListTypeInfo['all']['session']; listKey: string; - context: KeystoneContextFromListTypeInfo; }; export type AccessOperation = 'create' | 'query' | 'update' | 'delete'; diff --git a/packages/core/src/types/config/fields.ts b/packages/core/src/types/config/fields.ts index 21bdf97a76d..31b7e61b3b7 100644 --- a/packages/core/src/types/config/fields.ts +++ b/packages/core/src/types/config/fields.ts @@ -1,7 +1,7 @@ import type { CacheHint } from '@apollo/cache-control-types'; import type { FieldTypeFunc } from '../next-fields'; import type { BaseListTypeInfo } from '../type-info'; -import type { KeystoneContextFromListTypeInfo, MaybePromise } from '..'; +import type { KeystoneContext, MaybePromise } from '..'; import type { MaybeItemFunction, MaybeSessionFunction } from './lists'; import type { FieldHooks } from './hooks'; import type { FieldAccessControl } from './access-control'; @@ -11,14 +11,15 @@ export type BaseFields = { }; export type FilterOrderArgs = { - context: KeystoneContextFromListTypeInfo; - session: KeystoneContextFromListTypeInfo['session']; + context: KeystoneContext; + session?: ListTypeInfo['all']['session']; listKey: string; fieldKey: string; }; + export type CommonFieldConfig = { access?: FieldAccessControl; - hooks?: FieldHooks; + hooks?: FieldHooks; label?: string; ui?: { description?: string; diff --git a/packages/core/src/types/config/hooks.ts b/packages/core/src/types/config/hooks.ts index 3251991efed..ea861a5032b 100644 --- a/packages/core/src/types/config/hooks.ts +++ b/packages/core/src/types/config/hooks.ts @@ -96,31 +96,37 @@ export type ResolvedListHooks = { afterOperation?: AfterOperationHook; }; -export type FieldHooks = { +export type FieldHooks< + ListTypeInfo extends BaseListTypeInfo, + FieldKey extends ListTypeInfo['fields'] = ListTypeInfo['fields'] +> = { /** * Used to **modify the input** for create and update operations after default values and access control have been applied */ - resolveInput?: ResolveInputFieldHook; + resolveInput?: ResolveInputFieldHook; /** * Used to **validate the input** for create and update operations once all resolveInput hooks resolved */ - validateInput?: ValidateInputFieldHook; + validateInput?: ValidateInputFieldHook; /** * Used to **validate** that a delete operation can happen after access control has occurred */ - validateDelete?: ValidateDeleteFieldHook; + validateDelete?: ValidateDeleteFieldHook; /** * Used to **cause side effects** before a create, update, or delete operation once all validateInput hooks have resolved */ - beforeOperation?: BeforeOperationFieldHook; + beforeOperation?: BeforeOperationFieldHook; /** * Used to **cause side effects** after a create, update, or delete operation operation has occurred */ - afterOperation?: AfterOperationFieldHook; + afterOperation?: AfterOperationFieldHook; }; // TODO: one day -export type ResolvedFieldHooks = FieldHooks; +export type ResolvedFieldHooks = FieldHooks< + ListTypeInfo, + ListTypeInfo['fields'] +>; type ArgsForCreateOrUpdateOperation = | { @@ -154,7 +160,7 @@ type ArgsForCreateOrUpdateOperation = type ResolveInputFieldHook< ListTypeInfo extends BaseListTypeInfo, - FieldKey extends ListTypeInfo['fields'] = ListTypeInfo['fields'] + FieldKey extends ListTypeInfo['fields'] > = ( args: ArgsForCreateOrUpdateOperation & CommonArgs & { fieldKey: FieldKey } @@ -170,7 +176,7 @@ type ValidateInputHook = ( type ValidateInputFieldHook< ListTypeInfo extends BaseListTypeInfo, - FieldKey extends ListTypeInfo['fields'] = ListTypeInfo['fields'] + FieldKey extends ListTypeInfo['fields'] > = ( args: ArgsForCreateOrUpdateOperation & { addValidationError: (error: string) => void; @@ -187,7 +193,7 @@ type ValidateDeleteHook = ( type ValidateDeleteFieldHook< ListTypeInfo extends BaseListTypeInfo, - FieldKey extends ListTypeInfo['fields'] = ListTypeInfo['fields'] + FieldKey extends ListTypeInfo['fields'] > = ( args: { operation: 'delete'; @@ -211,7 +217,7 @@ type BeforeOperationHook = ( type BeforeOperationFieldHook< ListTypeInfo extends BaseListTypeInfo, - FieldKey extends ListTypeInfo['fields'] = ListTypeInfo['fields'] + FieldKey extends ListTypeInfo['fields'] > = ( args: ( | ArgsForCreateOrUpdateOperation @@ -274,7 +280,7 @@ type AfterOperationHook = ( type AfterOperationFieldHook< ListTypeInfo extends BaseListTypeInfo, - FieldKey extends ListTypeInfo['fields'] = ListTypeInfo['fields'] + FieldKey extends ListTypeInfo['fields'] > = ( args: ( | { diff --git a/packages/core/src/types/config/index.ts b/packages/core/src/types/config/index.ts index 8af63236d45..7b022369282 100644 --- a/packages/core/src/types/config/index.ts +++ b/packages/core/src/types/config/index.ts @@ -8,7 +8,7 @@ import type { Options as BodyParserOptions } from 'body-parser'; import type { AssetMode, BaseKeystoneTypeInfo, KeystoneContext, DatabaseProvider } from '..'; -import { SessionStrategy } from '../session'; +import type { SessionStrategy } from '../session'; import type { MaybePromise } from '../utils'; import type { ListSchemaConfig, @@ -95,11 +95,11 @@ export type StorageConfig = ( export type KeystoneConfig = { db: DatabaseConfig; - graphql?: GraphQLConfig; - lists: ListSchemaConfig; + graphql?: GraphQLConfig; + lists: ListSchemaConfig; ui?: AdminUIConfig; server?: ServerConfig; - session?: SessionStrategy; + session?: SessionStrategy; types?: { path?: string; }; @@ -225,7 +225,7 @@ export type ServerConfig = { // config.graphql -export type GraphQLConfig = { +export type GraphQLConfig = { // The path of the GraphQL API endpoint. Default: '/api/graphql'. path?: string; // The CORS configuration to use on the GraphQL API endpoint. @@ -243,7 +243,7 @@ export type GraphQLConfig = { * Additional options to pass into the ApolloServer constructor. * @see https://www.apollographql.com/docs/apollo-server/api/apollo-server/#constructor */ - apolloConfig?: Partial>; + apolloConfig?: Partial>>; /** * When an error is returned from the GraphQL API, Apollo can include a stacktrace * indicating where the error occurred. When Keystone is processing mutations, it diff --git a/packages/core/src/types/config/lists.ts b/packages/core/src/types/config/lists.ts index f389d30dddb..a90cac2b4a8 100644 --- a/packages/core/src/types/config/lists.ts +++ b/packages/core/src/types/config/lists.ts @@ -1,12 +1,16 @@ import type { CacheHint } from '@apollo/cache-control-types'; import type { MaybePromise } from '../utils'; import type { BaseListTypeInfo } from '../type-info'; -import type { KeystoneContextFromListTypeInfo } from '..'; +import type { KeystoneContext } from '../context'; import type { ListHooks } from './hooks'; import type { ListAccessControl } from './access-control'; import type { BaseFields, FilterOrderArgs } from './fields'; -export type ListSchemaConfig = Record>>; +// TODO: inline +export type ListSchemaConfig = Record< + string, + ListConfig> +>; export type IdFieldConfig = | { kind: 'cuid' | 'uuid'; type?: 'String' } @@ -21,10 +25,10 @@ export type IdFieldConfig = export type ListConfig< ListTypeInfo extends BaseListTypeInfo, - Fields extends BaseFields + Fields extends BaseFields = BaseFields // TODO: remove in breaking change > = { isSingleton?: boolean; - fields: Fields; + fields: BaseFields; /** * Controls what data users of the Admin UI and GraphQL can access and change @@ -33,7 +37,7 @@ export type ListConfig< access: ListAccessControl; /** Config for how this list should act in the Admin UI */ - ui?: ListAdminUIConfig; + ui?: ListAdminUIConfig>; /** * Hooks to modify the behaviour of GraphQL operations at certain points @@ -57,7 +61,7 @@ export type ListConfig< export type ListAdminUIConfig< ListTypeInfo extends BaseListTypeInfo, - Fields extends BaseFields + Fields extends BaseFields = BaseFields // TODO: remove in breaking change > = { /** * The field to use as a label in the Admin UI. If you want to base the label off more than a single field, use a virtual field and reference that field here. @@ -181,15 +185,15 @@ export type MaybeSessionFunction< > = | T | ((args: { - session: any; - context: KeystoneContextFromListTypeInfo; + context: KeystoneContext; + session?: ListTypeInfo['all']['session']; }) => MaybePromise); export type MaybeItemFunction = | T | ((args: { - session: any; - context: KeystoneContextFromListTypeInfo; + context: KeystoneContext; + session?: ListTypeInfo['all']['session']; item: ListTypeInfo['item']; }) => MaybePromise); diff --git a/packages/core/src/types/context.ts b/packages/core/src/types/context.ts index 76e8815e035..e406abb6eba 100644 --- a/packages/core/src/types/context.ts +++ b/packages/core/src/types/context.ts @@ -3,8 +3,8 @@ import type { Readable } from 'stream'; import type { GraphQLSchema, ExecutionResult, DocumentNode } from 'graphql'; import type { TypedDocumentNode } from '@graphql-typed-document-node/core'; import type { InitialisedList } from '../lib/core/types-for-lists'; -import type { BaseListTypeInfo } from './type-info'; -import type { BaseKeystoneTypeInfo, SessionStrategy } from '.'; +import type { SessionStrategy } from './session'; +import type { BaseListTypeInfo, BaseKeystoneTypeInfo } from './type-info'; export type KeystoneContext = { req?: IncomingMessage; @@ -14,7 +14,7 @@ export type KeystoneContext KeystoneContext; exitSudo: () => KeystoneContext; - withSession: (session: any) => KeystoneContext; + withSession: (session?: TypeInfo['session']) => KeystoneContext; withRequest: (req: IncomingMessage, res?: ServerResponse) => Promise>; prisma: TypeInfo['prisma']; files: FilesContext; @@ -27,8 +27,8 @@ export type KeystoneContext; }; - sessionStrategy?: SessionStrategy; - session?: any; + sessionStrategy?: SessionStrategy; + session?: TypeInfo['session']; }; // List item API diff --git a/packages/core/src/types/schema/graphql-ts-schema.ts b/packages/core/src/types/schema/graphql-ts-schema.ts index 1b864b8e0c9..05fe3bb8e23 100644 --- a/packages/core/src/types/schema/graphql-ts-schema.ts +++ b/packages/core/src/types/schema/graphql-ts-schema.ts @@ -99,7 +99,7 @@ type FieldFuncArgs< Args extends { [Key in keyof Args]: graphqlTsSchema.Arg }, Type extends OutputType, Key extends string, - Context extends KeystoneContext + Context extends KeystoneContext > = { args?: Args; type: Type; @@ -112,17 +112,15 @@ type FieldFunc = < Source, Type extends OutputType, Key extends string, - Context extends KeystoneContext, + Context extends KeystoneContext, Args extends { [Key in keyof Args]: graphqlTsSchema.Arg } = {} >( field: FieldFuncArgs -) => graphqlTsSchema.Field; +) => graphqlTsSchema.Field; export const field = fieldd as FieldFunc; // TODO: remove when we use { graphql } from '.keystone' -export type Context = KeystoneContext; - export const JSON = graphqlTsSchema.graphql.scalar( new GraphQLScalarType({ name: 'JSON', @@ -300,28 +298,41 @@ export const CalendarDay = graphqlTsSchema.graphql.scalar( }) ); -export type NullableType = graphqlTsSchema.NullableType; -export type Type = graphqlTsSchema.Type; -export type NullableOutputType = graphqlTsSchema.NullableOutputType; -export type OutputType = graphqlTsSchema.OutputType; +export type NullableType = + graphqlTsSchema.NullableType; +export type Type = graphqlTsSchema.Type; +export type NullableOutputType = + graphqlTsSchema.NullableOutputType; +export type OutputType = + graphqlTsSchema.OutputType; export type Field< Source, Args extends Record>, - TType extends OutputType, - Key extends string + TType extends OutputType, + Key extends string, + Context extends KeystoneContext = KeystoneContext > = graphqlTsSchema.Field; export type FieldResolver< Source, Args extends Record>, - TType extends OutputType + TType extends OutputType, + Context extends KeystoneContext = KeystoneContext > = graphqlTsSchema.FieldResolver; -export type ObjectType = graphqlTsSchema.ObjectType; -export type UnionType = graphqlTsSchema.UnionType; +export type ObjectType< + Source, + Context extends KeystoneContext = KeystoneContext +> = graphqlTsSchema.ObjectType; +export type UnionType< + Source, + Context extends KeystoneContext = KeystoneContext +> = graphqlTsSchema.UnionType; export type InterfaceType< Source, - Fields extends Record> + Fields extends Record, Context>>, + Context extends KeystoneContext = KeystoneContext > = graphqlTsSchema.InterfaceType; export type InterfaceField< Args extends Record>, - TType extends OutputType + TType extends OutputType, + Context extends KeystoneContext = KeystoneContext > = graphqlTsSchema.InterfaceField; diff --git a/packages/core/src/types/session.ts b/packages/core/src/types/session.ts index 67c7e0a6fb8..08d3981707f 100644 --- a/packages/core/src/types/session.ts +++ b/packages/core/src/types/session.ts @@ -1,28 +1,30 @@ -import type { JSONValue } from './utils'; -import type { KeystoneContext } from '.'; +import type { MaybePromise } from './utils'; +import type { BaseKeystoneTypeInfo, KeystoneContext } from '.'; export type SessionStrategy< - StoredSessionData, - Context extends KeystoneContext = KeystoneContext + Session, + TypeInfo extends BaseKeystoneTypeInfo = BaseKeystoneTypeInfo > = { - get: (args: { context: Context }) => Promise; - - start: (args: { data: StoredSessionData; context: Context }) => Promise; - - end: (args: { context: Context }) => Promise; + get: (args: { context: KeystoneContext }) => Promise; + start: (args: { context: KeystoneContext; data: Session }) => Promise; + end: (args: { context: KeystoneContext }) => Promise; }; -export type SessionStore = { - get(key: string): undefined | JSONValue | Promise; - // 😞 using any here rather than void to be compatible with Map. note that `| Promise` doesn't actually do anything type wise because it just turns into any, it's just to show intent here - set(key: string, value: JSONValue): any | Promise; - // 😞 | boolean is for compatibility with Map - delete(key: string): void | boolean | Promise; +/** @deprecated */ +export type SessionStore< + Session = any // TODO: remove any in breaking change +> = { + get(key: string): MaybePromise; + set(key: string, value: Session): void | Promise; + delete(key: string): void | Promise; }; -export type SessionStoreFunction = (args: { +/** @deprecated */ +export type SessionStoreFunction< + Session = any // TODO: remove any in breaking change +> = (args: { /** * The number of seconds that a cookie session be valid for */ maxAge: number; -}) => SessionStore; +}) => SessionStore; diff --git a/packages/core/src/types/type-info.ts b/packages/core/src/types/type-info.ts index 261a8df81ee..c64c5d46826 100644 --- a/packages/core/src/types/type-info.ts +++ b/packages/core/src/types/type-info.ts @@ -1,5 +1,5 @@ -import { KeystoneContext } from './context'; -import { BaseItem } from './next-fields'; +import type { KeystoneContext } from './context'; +import type { BaseItem } from './next-fields'; type GraphQLInput = Record; @@ -28,4 +28,8 @@ export type BaseListTypeInfo = { export type KeystoneContextFromListTypeInfo = KeystoneContext; -export type BaseKeystoneTypeInfo = { lists: Record; prisma: any }; +export type BaseKeystoneTypeInfo = { + lists: Record; + prisma: any; + session: any; +}; diff --git a/packages/core/src/types/type-tests.ts b/packages/core/src/types/type-tests.ts index 17fff55a4eb..82113f8fcbc 100644 --- a/packages/core/src/types/type-tests.ts +++ b/packages/core/src/types/type-tests.ts @@ -8,6 +8,7 @@ const someContext: KeystoneContext<{ ListOrSingleton: BaseListTypeInfo; }; prisma: any; + session: any; }> = undefined!; someContext.query.Singleton.findOne({}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81577c37304..ea67b906c82 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -858,7 +858,7 @@ importers: specifier: ~5.0.0 version: 5.0.2 - examples/custom-session-redis: + examples/custom-session-invalidation: dependencies: '@keystone-6/auth': specifier: ^7.0.0 @@ -869,9 +869,6 @@ importers: '@prisma/client': specifier: ^4.14.0 version: 4.14.0(prisma@4.14.0) - '@redis/client': - specifier: ^1.3.0 - version: 1.3.0 devDependencies: prisma: specifier: ^4.14.0 @@ -880,7 +877,7 @@ importers: specifier: ~5.0.0 version: 5.0.2 - examples/custom-session-validation: + examples/custom-session-redis: dependencies: '@keystone-6/auth': specifier: ^7.0.0 @@ -891,6 +888,9 @@ importers: '@prisma/client': specifier: ^4.14.0 version: 4.14.0(prisma@4.14.0) + '@redis/client': + specifier: ^1.3.0 + version: 1.3.0 devDependencies: prisma: specifier: ^4.14.0 @@ -1604,6 +1604,25 @@ importers: specifier: ~5.0.0 version: 5.0.2 + examples/usecase-blog-moderated: + dependencies: + '@keystone-6/auth': + specifier: ^7.0.0 + version: link:../../packages/auth + '@keystone-6/core': + specifier: ^5.0.0 + version: link:../../packages/core + '@prisma/client': + specifier: ^4.13.0 + version: 4.14.0(prisma@4.14.0) + devDependencies: + prisma: + specifier: ^4.13.0 + version: 4.14.0 + typescript: + specifier: ~5.0.0 + version: 5.0.2 + examples/usecase-roles: dependencies: '@keystone-6/auth': diff --git a/tests/admin-ui-tests/utils.ts b/tests/admin-ui-tests/utils.ts index edfa6e4db4a..9aed02838e1 100644 --- a/tests/admin-ui-tests/utils.ts +++ b/tests/admin-ui-tests/utils.ts @@ -132,7 +132,7 @@ export async function waitForIO(ksProcess: ExecaChildProcess, content: string) { ksProcess.stdout!.off('data', listener); ksProcess.stderr!.off('data', listener); - resolve(output); + return resolve(output); } ksProcess.stdout!.on('data', listener); diff --git a/tests/cli-tests/__snapshots__/artifacts.test.ts.snap b/tests/cli-tests/__snapshots__/artifacts.test.ts.snap index b3fbeed7b24..7613c97a5a5 100644 --- a/tests/cli-tests/__snapshots__/artifacts.test.ts.snap +++ b/tests/cli-tests/__snapshots__/artifacts.test.ts.snap @@ -127,10 +127,10 @@ type ResolvedTodoUpdateInput = { }; export declare namespace Lists { - export type Todo = import('@keystone-6/core').ListConfig; + export type Todo = import('@keystone-6/core').ListConfig, any>; namespace Todo { export type Item = import('@prisma/client').Todo; - export type TypeInfo = { + export type TypeInfo = { key: 'Todo'; isSingleton: false; fields: 'id' | 'title' @@ -146,23 +146,25 @@ export declare namespace Lists { create: ResolvedTodoCreateInput; update: ResolvedTodoUpdateInput; }; - all: __TypeInfo; + all: __TypeInfo; }; } } -export type Context = import('@keystone-6/core/types').KeystoneContext; +export type Context = import('@keystone-6/core/types').KeystoneContext>; +export type Config = import('@keystone-6/core/types').KeystoneConfig>; -export type TypeInfo = { +export type TypeInfo = { lists: { readonly Todo: Lists.Todo.TypeInfo; }; prisma: import('@prisma/client').PrismaClient; + session: Session; }; -type __TypeInfo = TypeInfo; +type __TypeInfo = TypeInfo; -export type Lists = { - [Key in keyof TypeInfo['lists']]?: import('@keystone-6/core').ListConfig +export type Lists = { + [Key in keyof TypeInfo['lists']]?: import('@keystone-6/core').ListConfig['lists'][Key], any> } & Record>; export {} diff --git a/tests/examples-smoke-tests/utils.ts b/tests/examples-smoke-tests/utils.ts index bb09ed56616..95e8e273fdc 100644 --- a/tests/examples-smoke-tests/utils.ts +++ b/tests/examples-smoke-tests/utils.ts @@ -1,6 +1,6 @@ import path from 'path'; import { promisify } from 'util'; -import execa from 'execa'; +import execa, { ExecaChildProcess } from 'execa'; import _treeKill from 'tree-kill'; import * as playwright from 'playwright'; @@ -35,19 +35,10 @@ const treeKill = promisify(_treeKill); // this'll take a while jest.setTimeout(10000000); -const promiseSignal = (): Promise & { resolve: () => void } => { - let resolve; - const promise = new Promise(_resolve => { - resolve = _resolve; - }); - return Object.assign(promise, { resolve: resolve as any }); -}; - -export const initFirstItemTest = (getPage: () => playwright.Page) => { +export function initFirstItemTest(getPage: () => playwright.Page) { test('init first item', async () => { const page = getPage(); await page.fill('label:has-text("Name") >> .. >> input', 'Admin'); - await page.fill('label:has-text("Email") >> .. >> input', 'admin@keystonejs.com'); await page.click('button:has-text("Set Password")'); await page.fill('[placeholder="New Password"]', 'password'); await page.fill('[placeholder="Confirm Password"]', 'password'); @@ -56,7 +47,26 @@ export const initFirstItemTest = (getPage: () => playwright.Page) => { await page.click('text=Continue'); await page.waitForSelector('text=Signed in as Admin'); }); -}; +} + +// TODO: merge with tests/admin-ui-tests/utils.ts copy +export async function waitForIO(ksProcess: ExecaChildProcess, content: string) { + return await new Promise(resolve => { + let output = ''; + function listener(chunk: Buffer) { + output += chunk.toString('utf8'); + if (process.env.VERBOSE) console.log(chunk.toString('utf8')); + if (!output.includes(content)) return; + + ksProcess.stdout!.off('data', listener); + ksProcess.stderr!.off('data', listener); + return resolve(output); + } + + ksProcess.stdout!.on('data', listener); + ksProcess.stderr!.on('data', listener); + }); +} export const exampleProjectTests = ( exampleName: string, @@ -71,31 +81,15 @@ export const exampleProjectTests = ( }); async function startKeystone(command: 'start' | 'dev') { - const keystoneProcess = execa('pnpm', ['keystone', command], { + const ksProcess = execa('pnpm', ['keystone', command], { cwd: projectDir, env: process.env, }); - const adminUIReady = promiseSignal(); - const listener = (chunk: any) => { - const stringified = chunk.toString('utf8'); - if (process.env.VERBOSE) { - console.log(stringified); - } - if (stringified.includes('Admin UI ready')) { - adminUIReady.resolve(); - } - }; - keystoneProcess.stdout!.on('data', listener); - keystoneProcess.stderr!.on('data', listener); cleanupKeystoneProcess = async () => { - keystoneProcess.stdout!.off('data', listener); - keystoneProcess.stderr!.off('data', listener); - // childProcess.kill will only kill the direct child process - // so we use tree-kill to kill the process and it's children - await treeKill(keystoneProcess.pid!); + await treeKill(ksProcess.pid!); }; - await adminUIReady; + await waitForIO(ksProcess, 'Admin UI ready'); } if (mode === 'dev') {