canopycms-auth-dev 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,41 @@
1
+ import { extractHeaders } from 'canopycms/auth';
2
+ import { getDevUserCookieFromHeaders, DEFAULT_USER_ID } from './cookie-utils';
3
+ import { DEV_ADMIN_USER_ID } from './dev-plugin';
4
+ /**
5
+ * Test user key → dev user ID mapping.
6
+ * Matches the mapping in DevAuthPlugin.mapTestUserKey().
7
+ */
8
+ const TEST_USER_MAP = {
9
+ admin: DEV_ADMIN_USER_ID,
10
+ editor: 'dev_user1_2nK8mP4xL9',
11
+ viewer: 'dev_user2_7qR3tY6wN2',
12
+ reviewer: 'dev_reviewer_9aB4cD2eF7',
13
+ };
14
+ /**
15
+ * Creates a token verifier for dev auth.
16
+ * Extracts userId from X-Test-User header, x-dev-user-id header,
17
+ * or canopy-dev-user cookie — same logic as DevAuthPlugin.authenticate().
18
+ *
19
+ * Used with CachingAuthPlugin in prod-sim mode to simulate the prod
20
+ * code path (token verification + cached metadata lookup) using dev users.
21
+ */
22
+ export function createDevTokenVerifier(options) {
23
+ const defaultUserId = options?.defaultUserId ?? DEFAULT_USER_ID;
24
+ return async (context) => {
25
+ const headers = extractHeaders(context);
26
+ if (!headers)
27
+ return null;
28
+ // Same extraction logic as DevAuthPlugin.authenticate()
29
+ let userId = headers.get('X-Test-User');
30
+ if (!userId) {
31
+ userId = headers.get('x-dev-user-id') ?? getDevUserCookieFromHeaders(headers);
32
+ }
33
+ if (!userId) {
34
+ userId = defaultUserId;
35
+ }
36
+ // Map test user keys to dev user IDs
37
+ const mapped = TEST_USER_MAP[userId] ?? userId;
38
+ return { userId: mapped };
39
+ };
40
+ }
41
+ //# sourceMappingURL=jwt-verifier.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"jwt-verifier.js","sourceRoot":"","sources":["../src/jwt-verifier.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAA;AAE/C,OAAO,EAAE,2BAA2B,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAA;AAC7E,OAAO,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAA;AAEhD;;;GAGG;AACH,MAAM,aAAa,GAA2B;IAC5C,KAAK,EAAE,iBAAiB;IACxB,MAAM,EAAE,sBAAsB;IAC9B,MAAM,EAAE,sBAAsB;IAC9B,QAAQ,EAAE,yBAAyB;CACpC,CAAA;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,sBAAsB,CAAC,OAAoC;IACzE,MAAM,aAAa,GAAG,OAAO,EAAE,aAAa,IAAI,eAAe,CAAA;IAE/D,OAAO,KAAK,EAAE,OAAgB,EAAE,EAAE;QAChC,MAAM,OAAO,GAAG,cAAc,CAAC,OAAO,CAAC,CAAA;QACvC,IAAI,CAAC,OAAO;YAAE,OAAO,IAAI,CAAA;QAEzB,wDAAwD;QACxD,IAAI,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,aAAa,CAAC,CAAA;QACvC,IAAI,CAAC,MAAM,EAAE,CAAC;YACZ,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,eAAe,CAAC,IAAI,2BAA2B,CAAC,OAAO,CAAC,CAAA;QAC/E,CAAC;QACD,IAAI,CAAC,MAAM,EAAE,CAAC;YACZ,MAAM,GAAG,aAAa,CAAA;QACxB,CAAC;QAED,qCAAqC;QACrC,MAAM,MAAM,GAAG,aAAa,CAAC,MAAM,CAAC,IAAI,MAAM,CAAA;QAE9C,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,CAAA;IAC3B,CAAC,CAAA;AACH,CAAC"}
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "canopycms-auth-dev",
3
+ "version": "0.0.0",
4
+ "description": "Development authentication provider for CanopyCMS",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/safeinsights/canopycms.git",
9
+ "directory": "packages/canopycms-auth-dev"
10
+ },
11
+ "private": false,
12
+ "type": "module",
13
+ "main": "dist/index.js",
14
+ "types": "dist/index.d.ts",
15
+ "exports": {
16
+ ".": "./src/index.ts",
17
+ "./client": "./src/client.ts",
18
+ "./cache-writer": "./src/cache-writer.ts"
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "src"
23
+ ],
24
+ "publishConfig": {
25
+ "exports": {
26
+ ".": {
27
+ "import": "./dist/index.js",
28
+ "types": "./dist/index.d.ts"
29
+ },
30
+ "./client": {
31
+ "import": "./dist/client.js",
32
+ "types": "./dist/client.d.ts"
33
+ },
34
+ "./cache-writer": {
35
+ "import": "./dist/cache-writer.js",
36
+ "types": "./dist/cache-writer.d.ts"
37
+ }
38
+ }
39
+ },
40
+ "scripts": {
41
+ "build": "tsc -p tsconfig.build.json",
42
+ "test": "vitest run --reporter=dot",
43
+ "typecheck": "tsc --noEmit"
44
+ },
45
+ "engines": {
46
+ "node": ">=18"
47
+ },
48
+ "peerDependencies": {
49
+ "canopycms": "*",
50
+ "react": "^18.0.0 || ^19.0.0",
51
+ "@mantine/core": "^7.0.0",
52
+ "@mantine/hooks": "^7.0.0",
53
+ "@mantine/modals": "^7.0.0",
54
+ "react-icons": "^5.0.0"
55
+ },
56
+ "devDependencies": {
57
+ "@types/node": "^22.9.0",
58
+ "@types/react": "^18.0.0",
59
+ "typescript": "^5.6.3",
60
+ "vitest": "^1.6.0"
61
+ }
62
+ }
@@ -0,0 +1,46 @@
1
+ 'use client'
2
+
3
+ import { useState, useEffect } from 'react'
4
+ import { ActionIcon, Avatar } from '@mantine/core'
5
+ import { UserSwitcherModal } from './UserSwitcherModal'
6
+ import { DEFAULT_USERS } from './dev-plugin'
7
+ import { getDevUserCookie, DEFAULT_USER_ID } from './cookie-utils'
8
+
9
+ /**
10
+ * User switcher button component that shows current user avatar and opens modal
11
+ */
12
+ export function UserSwitcherButton() {
13
+ const [opened, setOpened] = useState(false)
14
+ const [mounted, setMounted] = useState(false)
15
+
16
+ // Only read cookie after mount to avoid hydration mismatch
17
+ useEffect(() => {
18
+ setMounted(true)
19
+ }, [])
20
+
21
+ // Read current user from cookie (only on client)
22
+ const currentUserId = mounted ? (getDevUserCookie() ?? DEFAULT_USER_ID) : DEFAULT_USER_ID
23
+ const currentUser = DEFAULT_USERS.find((u) => u.userId === currentUserId)
24
+
25
+ return (
26
+ <>
27
+ <ActionIcon
28
+ variant="subtle"
29
+ size="lg"
30
+ radius="md"
31
+ onClick={() => setOpened(true)}
32
+ aria-label="Switch user"
33
+ >
34
+ <Avatar size="sm" color="blue">
35
+ {currentUser?.name[0] ?? 'U'}
36
+ </Avatar>
37
+ </ActionIcon>
38
+
39
+ <UserSwitcherModal
40
+ opened={opened}
41
+ onClose={() => setOpened(false)}
42
+ currentUserId={currentUserId}
43
+ />
44
+ </>
45
+ )
46
+ }
@@ -0,0 +1,63 @@
1
+ 'use client'
2
+
3
+ import { Modal, Stack, Paper, Group, Avatar, Text, Badge } from '@mantine/core'
4
+ import { MdCheck } from 'react-icons/md'
5
+ import { DEFAULT_USERS } from './dev-plugin'
6
+ import { setDevUserCookie } from './cookie-utils'
7
+
8
+ interface Props {
9
+ opened: boolean
10
+ onClose: () => void
11
+ currentUserId: string
12
+ }
13
+
14
+ /**
15
+ * User switcher modal component that displays all available dev users
16
+ */
17
+ export function UserSwitcherModal({ opened, onClose, currentUserId }: Props) {
18
+ const switchUser = (userId: string) => {
19
+ // Set cookie for 7 days
20
+ setDevUserCookie(userId)
21
+ // Reload to apply new user
22
+ window.location.reload()
23
+ }
24
+
25
+ return (
26
+ <Modal opened={opened} onClose={onClose} title="Switch Development User">
27
+ <Stack gap="sm">
28
+ {DEFAULT_USERS.map((user) => (
29
+ <Paper
30
+ key={user.userId}
31
+ p="md"
32
+ withBorder
33
+ style={{ cursor: 'pointer' }}
34
+ onClick={() => switchUser(user.userId)}
35
+ >
36
+ <Group justify="space-between" mb="xs">
37
+ <Group>
38
+ <Avatar color="blue">{user.name[0]}</Avatar>
39
+ <div>
40
+ <Text fw={500}>{user.name}</Text>
41
+ <Text size="sm" c="dimmed">
42
+ {user.email}
43
+ </Text>
44
+ </div>
45
+ </Group>
46
+ {user.userId === currentUserId && <MdCheck size={20} />}
47
+ </Group>
48
+
49
+ {user.externalGroups.length > 0 && (
50
+ <Group gap="xs">
51
+ {user.externalGroups.map((g) => (
52
+ <Badge key={g} variant="outline" size="sm">
53
+ {g}
54
+ </Badge>
55
+ ))}
56
+ </Group>
57
+ )}
58
+ </Paper>
59
+ ))}
60
+ </Stack>
61
+ </Modal>
62
+ )
63
+ }
@@ -0,0 +1,61 @@
1
+ import { writeAuthCacheSnapshot } from 'canopycms/auth/cache'
2
+ import { DEFAULT_USERS, DEFAULT_GROUPS } from './dev-plugin'
3
+ import type { DevUser, DevGroup } from './dev-plugin'
4
+
5
+ export interface RefreshDevCacheOptions {
6
+ /** Directory to write cache files to (e.g., .canopy-prod-sim/.cache) */
7
+ cachePath: string
8
+ /** Custom users (defaults to DEFAULT_USERS) */
9
+ users?: DevUser[]
10
+ /** Custom groups (defaults to DEFAULT_GROUPS) */
11
+ groups?: DevGroup[]
12
+ }
13
+
14
+ /**
15
+ * Write dev users/groups to cache files for FileBasedAuthCache.
16
+ *
17
+ * This is the dev-auth equivalent of refreshClerkCache() — it populates
18
+ * the same JSON files that CachingAuthPlugin reads. Since dev users are
19
+ * hardcoded, no API calls are needed.
20
+ *
21
+ * Used by the worker's `run-once` command in prod-sim mode with dev auth.
22
+ */
23
+ export async function refreshDevCache(
24
+ options: RefreshDevCacheOptions,
25
+ ): Promise<{ userCount: number; groupCount: number }> {
26
+ const { cachePath } = options
27
+ const users = options.users ?? DEFAULT_USERS
28
+ const groups = options.groups ?? DEFAULT_GROUPS
29
+
30
+ const usersData = {
31
+ users: users.map((u) => ({
32
+ id: u.userId,
33
+ name: u.name,
34
+ email: u.email,
35
+ avatarUrl: u.avatarUrl,
36
+ })),
37
+ }
38
+
39
+ const groupsData = {
40
+ groups: groups.map((g) => ({
41
+ id: g.id,
42
+ name: g.name,
43
+ description: g.description,
44
+ })),
45
+ }
46
+
47
+ const membershipsData = {
48
+ memberships: Object.fromEntries(
49
+ users.filter((u) => u.externalGroups.length > 0).map((u) => [u.userId, u.externalGroups]),
50
+ ),
51
+ }
52
+
53
+ // Write cache files atomically via snapshot directory + symlink swap
54
+ await writeAuthCacheSnapshot(cachePath, {
55
+ 'users.json': usersData,
56
+ 'orgs.json': groupsData,
57
+ 'memberships.json': membershipsData,
58
+ })
59
+
60
+ return { userCount: users.length, groupCount: groups.length }
61
+ }
package/src/client.ts ADDED
@@ -0,0 +1,34 @@
1
+ 'use client'
2
+
3
+ import type { CanopyClientConfig } from 'canopycms/client'
4
+ import { UserSwitcherButton } from './UserSwitcherButton'
5
+ import { clearDevUserCookie } from './cookie-utils'
6
+
7
+ /**
8
+ * Hook that provides dev auth handlers and components for CanopyCMS editor.
9
+ * Model after: packages/canopycms-auth-clerk/src/client.ts
10
+ *
11
+ * @example
12
+ * ```tsx
13
+ * import { useDevAuthConfig } from 'canopycms-auth-dev/client'
14
+ * import config from '../../canopycms.config'
15
+ *
16
+ * export default function EditPage() {
17
+ * const devAuth = useDevAuthConfig()
18
+ * const editorConfig = config.client(devAuth)
19
+ * return <CanopyEditorPage config={editorConfig} />
20
+ * }
21
+ * ```
22
+ */
23
+ export function useDevAuthConfig(): Pick<CanopyClientConfig, 'editor'> {
24
+ return {
25
+ editor: {
26
+ AccountComponent: UserSwitcherButton,
27
+ onLogoutClick: () => {
28
+ // Reset to default user
29
+ clearDevUserCookie()
30
+ window.location.reload()
31
+ },
32
+ },
33
+ }
34
+ }
@@ -0,0 +1,44 @@
1
+ import type { HeadersLike } from 'canopycms/auth'
2
+
3
+ export const DEV_USER_COOKIE_NAME = 'canopy-dev-user'
4
+ export const DEV_USER_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 // 7 days
5
+ export const DEFAULT_USER_ID = 'dev_user1_2nK8mP4xL9'
6
+
7
+ /**
8
+ * Server-side: Extract cookie value from HTTP headers
9
+ */
10
+ export function getDevUserCookieFromHeaders(headers: HeadersLike): string | null {
11
+ const cookie = headers.get('Cookie')
12
+ if (!cookie) return null
13
+
14
+ const match = cookie.match(new RegExp(`${DEV_USER_COOKIE_NAME}=([^;]+)`))
15
+ return match?.[1] ?? null
16
+ }
17
+
18
+ /**
19
+ * Client-side: Read cookie from document.cookie
20
+ */
21
+ export function getDevUserCookie(): string | null {
22
+ if (typeof document === 'undefined') return null
23
+
24
+ const match = document.cookie.match(new RegExp(`${DEV_USER_COOKIE_NAME}=([^;]+)`))
25
+ return match?.[1] ?? null
26
+ }
27
+
28
+ /**
29
+ * Client-side: Set dev user cookie
30
+ */
31
+ export function setDevUserCookie(userId: string): void {
32
+ if (typeof document === 'undefined') return
33
+
34
+ document.cookie = `${DEV_USER_COOKIE_NAME}=${userId}; path=/; max-age=${DEV_USER_COOKIE_MAX_AGE}; SameSite=Lax`
35
+ }
36
+
37
+ /**
38
+ * Client-side: Clear dev user cookie (logout)
39
+ */
40
+ export function clearDevUserCookie(): void {
41
+ if (typeof document === 'undefined') return
42
+
43
+ document.cookie = `${DEV_USER_COOKIE_NAME}=; path=/; max-age=0`
44
+ }