@q3assets/auth 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
- # @q3/auth
1
+ # @q3assets/auth
2
2
 
3
- Shared authentication package for SHIRE project dashboards. Provides M365-safe magic link auth with Supabase.
3
+ Shared authentication package for SHIRE project dashboards. Published to npm as `@q3assets/auth`.
4
4
 
5
5
  ## What's included
6
6
 
@@ -17,38 +17,35 @@ Shared authentication package for SHIRE project dashboards. Provides M365-safe m
17
17
 
18
18
  ## Consuming from a project dashboard
19
19
 
20
- All SHIRE projects share a filesystem via Dropbox. Shared packages live at `Q3/packages/`. Every project dashboard consumes them the same way — no monorepo, no registry.
20
+ **1. Install:**
21
21
 
22
- **1. Add the dependency** in `package.json`:
23
-
24
- ```json
25
- "@q3/auth": "file:../../Q3/packages/auth"
22
+ ```bash
23
+ npm install @q3assets/auth
26
24
  ```
27
25
 
28
- Path is relative from your dashboard root to this package. From `SHIRE/projects/[PROJECT]/dashboard/` it's always `../../Q3/packages/auth`.
29
-
30
- **2. Configure `next.config.ts`:**
26
+ **2. Add `transpilePackages` to `next.config.ts`:**
31
27
 
32
28
  ```typescript
33
- import { join } from "path"
34
-
35
29
  const nextConfig = {
36
- transpilePackages: ["@q3/auth"],
37
- turbopack: {
38
- root: join(__dirname, "../.."), // common parent: SHIRE/projects/
39
- },
30
+ transpilePackages: ["@q3assets/auth"],
40
31
  }
41
32
  ```
42
33
 
43
- `transpilePackages` tells Next.js to compile the package's TypeScript. `turbopack.root` tells Turbopack to trust the shared Dropbox filesystem so it follows the symlink.
34
+ This is required because the package ships TypeScript source. One line, standard Next.js pattern.
44
35
 
45
- **3. Install:** `npm install` — creates a symlink. Changes to this package are picked up immediately.
36
+ **3. Import:**
46
37
 
47
- Import as `@q3/auth/login`, `@q3/auth/confirm`, etc.
38
+ ```typescript
39
+ import { Login } from "@q3assets/auth/login"
40
+ import { AuthConfirm } from "@q3assets/auth/confirm"
41
+ import { AuthCallback } from "@q3assets/auth/callback"
42
+ import { createAuthMiddleware } from "@q3assets/auth/middleware"
43
+ import { createBrowserClient } from "@q3assets/auth/clients"
44
+ ```
48
45
 
49
46
  ### Pages and middleware
50
47
 
51
- Create three pages and a middleware. Reference implementation: `Q3/dashboard/src/`
48
+ Create three pages and a middleware. Reference implementation: `SHIRE/projects/Q3/dashboard/src/`
52
49
 
53
50
  ```
54
51
  app/login/page.tsx → Login component
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@q3assets/auth",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "exports": {
5
5
  ".": "./src/index.ts",
6
6
  "./login": "./src/login.tsx",
@@ -8,6 +8,8 @@
8
8
  "./confirm": "./src/confirm.tsx",
9
9
  "./clients": "./src/clients.ts",
10
10
  "./admin": "./src/admin.ts",
11
+ "./admin-panel": "./src/admin-panel.tsx",
12
+ "./admin-routes": "./src/admin-routes.ts",
11
13
  "./middleware": "./src/middleware.ts"
12
14
  },
13
15
  "dependencies": {
@@ -0,0 +1,187 @@
1
+ "use client"
2
+
3
+ import { useEffect, useState } from "react"
4
+ import type { SupabaseClient } from "@supabase/supabase-js"
5
+
6
+ interface AppUser {
7
+ id: string
8
+ email: string
9
+ name: string | null
10
+ role: string
11
+ created_at: string
12
+ last_sign_in_at: string | null
13
+ }
14
+
15
+ interface AdminPanelProps {
16
+ supabase: SupabaseClient
17
+ onClose: () => void
18
+ apiBase?: string
19
+ }
20
+
21
+ export function AdminPanel({ supabase, onClose, apiBase = "/api/admin/users" }: AdminPanelProps) {
22
+ const [users, setUsers] = useState<AppUser[]>([])
23
+ const [loading, setLoading] = useState(true)
24
+ const [inviteName, setInviteName] = useState("")
25
+ const [inviteEmail, setInviteEmail] = useState("")
26
+ const [inviteRole, setInviteRole] = useState<"user" | "admin">("user")
27
+ const [sending, setSending] = useState(false)
28
+ const [message, setMessage] = useState("")
29
+ const [error, setError] = useState("")
30
+ const [editingUser, setEditingUser] = useState<AppUser | null>(null)
31
+ const [editName, setEditName] = useState("")
32
+
33
+ async function getAuthHeader() {
34
+ const { data: { session } } = await supabase.auth.getSession()
35
+ return { Authorization: `Bearer ${session?.access_token}` }
36
+ }
37
+
38
+ async function loadUsers() {
39
+ const headers = await getAuthHeader()
40
+ const res = await fetch(apiBase, { headers })
41
+ const data = await res.json()
42
+ if (res.ok) {
43
+ setUsers(data.users)
44
+ setError("")
45
+ } else {
46
+ setError(data.error || "Failed to load users")
47
+ }
48
+ setLoading(false)
49
+ }
50
+
51
+ useEffect(() => { loadUsers() }, [])
52
+
53
+ async function handleInvite(e: React.FormEvent) {
54
+ e.preventDefault()
55
+ setSending(true)
56
+ setMessage("")
57
+
58
+ const headers = await getAuthHeader()
59
+ const res = await fetch(apiBase, {
60
+ method: "POST",
61
+ headers: { ...headers, "Content-Type": "application/json" },
62
+ body: JSON.stringify({ email: inviteEmail, name: inviteName, role: inviteRole }),
63
+ })
64
+
65
+ const data = await res.json()
66
+ setSending(false)
67
+
68
+ if (res.ok) {
69
+ setMessage(`Invited ${inviteName || inviteEmail}`)
70
+ setInviteEmail("")
71
+ setInviteName("")
72
+ loadUsers()
73
+ } else {
74
+ setMessage(data.error || "Failed to invite")
75
+ }
76
+ }
77
+
78
+ async function handleRemove(user: AppUser) {
79
+ if (!confirm(`Remove ${user.email}? This cannot be undone.`)) return
80
+ const headers = await getAuthHeader()
81
+ const res = await fetch(`${apiBase}/${user.id}`, { method: "DELETE", headers })
82
+ if (res.ok) loadUsers()
83
+ else alert((await res.json()).error || "Failed to remove user")
84
+ }
85
+
86
+ async function handleToggleRole(user: AppUser) {
87
+ const newRole = user.role === "admin" ? "user" : "admin"
88
+ const headers = await getAuthHeader()
89
+ const res = await fetch(`${apiBase}/${user.id}`, {
90
+ method: "PATCH",
91
+ headers: { ...headers, "Content-Type": "application/json" },
92
+ body: JSON.stringify({ role: newRole }),
93
+ })
94
+ if (res.ok) loadUsers()
95
+ else alert((await res.json()).error || "Failed to update role")
96
+ }
97
+
98
+ async function handleSaveName(user: AppUser) {
99
+ const headers = await getAuthHeader()
100
+ const res = await fetch(`${apiBase}/${user.id}`, {
101
+ method: "PATCH",
102
+ headers: { ...headers, "Content-Type": "application/json" },
103
+ body: JSON.stringify({ name: editName }),
104
+ })
105
+ if (res.ok) { setEditingUser(null); setEditName(""); loadUsers() }
106
+ else alert((await res.json()).error || "Failed to update name")
107
+ }
108
+
109
+ return (
110
+ <div className="fixed inset-0 z-50 flex justify-end">
111
+ <div className="absolute inset-0 bg-black/50" onClick={onClose} />
112
+ <div className="relative w-full max-w-lg overflow-y-auto bg-[var(--bg)] border-l border-[var(--border)] shadow-2xl">
113
+ <div className="sticky top-0 z-10 flex items-center justify-between border-b border-[var(--border)] bg-[var(--bg)] px-5 py-4">
114
+ <h2 className="text-lg font-semibold">Admin</h2>
115
+ <button onClick={onClose} className="rounded-lg p-1.5 text-[var(--text-muted)] hover:bg-[var(--bg-card)] hover:text-[var(--text)]">
116
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 6L6 18M6 6l12 12" /></svg>
117
+ </button>
118
+ </div>
119
+
120
+ <div className="p-5 space-y-6">
121
+ <div className="rounded-xl border border-[var(--border)] bg-[var(--bg-card)] p-4">
122
+ <h3 className="text-sm font-medium mb-3">Invite User</h3>
123
+ <form onSubmit={handleInvite} className="space-y-3">
124
+ <input type="text" value={inviteName} onChange={(e) => setInviteName(e.target.value)} placeholder="Name (required)" required className="w-full rounded-lg border border-[var(--border)] bg-[var(--bg)] px-3 py-2 text-sm text-[var(--text)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-1 focus:ring-[var(--accent)]" />
125
+ <input type="email" value={inviteEmail} onChange={(e) => setInviteEmail(e.target.value)} placeholder="email@example.com" required className="w-full rounded-lg border border-[var(--border)] bg-[var(--bg)] px-3 py-2 text-sm text-[var(--text)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-1 focus:ring-[var(--accent)]" />
126
+ <div className="flex items-center gap-3">
127
+ <select value={inviteRole} onChange={(e) => setInviteRole(e.target.value as "user" | "admin")} className="rounded-lg border border-[var(--border)] bg-[var(--bg)] px-3 py-2 text-sm text-[var(--text)] focus:border-[var(--accent)] focus:outline-none">
128
+ <option value="user">User</option>
129
+ <option value="admin">Admin</option>
130
+ </select>
131
+ <button type="submit" disabled={sending} className="flex-1 rounded-lg bg-[var(--accent)] px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-[var(--accent-light)] disabled:opacity-50">
132
+ {sending ? "Sending..." : "Send Invite"}
133
+ </button>
134
+ </div>
135
+ {message && <p className="text-xs text-[var(--text-muted)]">{message}</p>}
136
+ </form>
137
+ </div>
138
+
139
+ <div>
140
+ <h3 className="text-sm font-medium mb-3">Users ({users.length})</h3>
141
+ {error && <p className="mb-3 text-sm text-[var(--danger)]">{error}</p>}
142
+ {loading ? (
143
+ <p className="text-sm text-[var(--text-muted)]">Loading...</p>
144
+ ) : users.length === 0 ? (
145
+ <p className="text-sm text-[var(--text-muted)]">No users yet.</p>
146
+ ) : (
147
+ <div className="space-y-2">
148
+ {users.map((user) => (
149
+ <div key={user.id} className="rounded-lg border border-[var(--border)] bg-[var(--bg-card)] px-4 py-3">
150
+ {editingUser?.id === user.id ? (
151
+ <div className="flex items-center gap-2">
152
+ <input type="text" value={editName} onChange={(e) => setEditName(e.target.value)} placeholder="Name" autoFocus onKeyDown={(e) => { if (e.key === "Enter") handleSaveName(user); if (e.key === "Escape") { setEditingUser(null); setEditName("") } }} className="flex-1 rounded-lg border border-[var(--border)] bg-[var(--bg)] px-3 py-1.5 text-sm text-[var(--text)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-1 focus:ring-[var(--accent)]" />
153
+ <button onClick={() => handleSaveName(user)} className="rounded-lg bg-[var(--accent)] px-3 py-1.5 text-xs font-medium text-white hover:bg-[var(--accent-light)]">Save</button>
154
+ <button onClick={() => { setEditingUser(null); setEditName("") }} className="rounded-lg px-3 py-1.5 text-xs text-[var(--text-muted)] hover:text-[var(--text)]">Cancel</button>
155
+ </div>
156
+ ) : (
157
+ <div className="flex items-center justify-between">
158
+ <div className="min-w-0 flex-1">
159
+ <button onClick={() => { setEditingUser(user); setEditName(user.name || "") }} className="text-sm font-medium truncate hover:text-[var(--accent)] transition-colors text-left" title="Click to edit name">
160
+ {user.name || user.email}
161
+ </button>
162
+ <p className="text-xs text-[var(--text-muted)] truncate">
163
+ {user.name ? user.email : null}
164
+ {user.name && user.last_sign_in_at ? " · " : ""}
165
+ {user.last_sign_in_at ? `Last login ${new Date(user.last_sign_in_at).toLocaleDateString()}` : "Never logged in"}
166
+ </p>
167
+ </div>
168
+ <div className="flex items-center gap-2 ml-3">
169
+ <button onClick={() => handleToggleRole(user)} className={`rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors ${user.role === "admin" ? "bg-indigo-900/50 text-indigo-300 hover:bg-indigo-900/70" : "bg-zinc-800 text-zinc-300 hover:bg-zinc-700"}`}>
170
+ {user.role}
171
+ </button>
172
+ <button onClick={() => handleRemove(user)} className="rounded-lg p-1 text-[var(--text-muted)] hover:text-[var(--danger)] transition-colors" title="Remove user">
173
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6h14" /></svg>
174
+ </button>
175
+ </div>
176
+ </div>
177
+ )}
178
+ </div>
179
+ ))}
180
+ </div>
181
+ )}
182
+ </div>
183
+ </div>
184
+ </div>
185
+ </div>
186
+ )
187
+ }
@@ -0,0 +1,73 @@
1
+ import { createAdminClient, requireAdmin } from "./admin"
2
+
3
+ function json(data: any, status = 200) {
4
+ return new Response(JSON.stringify(data), {
5
+ status,
6
+ headers: { "Content-Type": "application/json" },
7
+ })
8
+ }
9
+
10
+ export async function handleListUsers(request: Request) {
11
+ const user = await requireAdmin(request)
12
+ if (!user) return json({ error: "Unauthorized" }, 401)
13
+
14
+ const admin = createAdminClient()
15
+ const { data, error } = await admin.auth.admin.listUsers()
16
+ if (error) return json({ error: error.message }, 500)
17
+
18
+ const users = data.users.map((u) => ({
19
+ id: u.id,
20
+ email: u.email,
21
+ name: u.user_metadata?.name || null,
22
+ role: u.user_metadata?.role || u.app_metadata?.role || "user",
23
+ created_at: u.created_at,
24
+ last_sign_in_at: u.last_sign_in_at,
25
+ }))
26
+
27
+ return json({ users })
28
+ }
29
+
30
+ export async function handleInviteUser(request: Request) {
31
+ const user = await requireAdmin(request)
32
+ if (!user) return json({ error: "Unauthorized" }, 401)
33
+
34
+ const { email, name, role } = await request.json()
35
+ if (!email) return json({ error: "Email required" }, 400)
36
+
37
+ const admin = createAdminClient()
38
+ const siteUrl = new URL(request.url).origin
39
+
40
+ const { data, error } = await admin.auth.admin.inviteUserByEmail(email, {
41
+ redirectTo: `${siteUrl}/auth/confirm`,
42
+ data: { name: name || email.split("@")[0], role: role || "user" },
43
+ })
44
+
45
+ if (error) return json({ error: error.message }, 500)
46
+ return json({ user: data.user })
47
+ }
48
+
49
+ export async function handleUpdateUser(request: Request, userId: string) {
50
+ const user = await requireAdmin(request)
51
+ if (!user) return json({ error: "Unauthorized" }, 401)
52
+
53
+ const body = await request.json()
54
+ const admin = createAdminClient()
55
+
56
+ const update: any = {}
57
+ if (body.role) update.app_metadata = { role: body.role }
58
+ if (body.name !== undefined) update.user_metadata = { name: body.name }
59
+
60
+ const { error } = await admin.auth.admin.updateUserById(userId, update)
61
+ if (error) return json({ error: error.message }, 500)
62
+ return json({ ok: true })
63
+ }
64
+
65
+ export async function handleDeleteUser(request: Request, userId: string) {
66
+ const user = await requireAdmin(request)
67
+ if (!user) return json({ error: "Unauthorized" }, 401)
68
+
69
+ const admin = createAdminClient()
70
+ const { error } = await admin.auth.admin.deleteUser(userId)
71
+ if (error) return json({ error: error.message }, 500)
72
+ return json({ ok: true })
73
+ }
package/src/admin.ts CHANGED
@@ -1,52 +1,44 @@
1
+ import { createClient } from "@supabase/supabase-js"
1
2
  import type { SupabaseClient } from "@supabase/supabase-js"
2
3
 
3
- /**
4
- * Check if the requesting user is an admin. Reads the session from the
5
- * Authorization header (browser client sends this automatically via cookies
6
- * or bearer token). Checks for an `is_admin` flag in user metadata.
7
- *
8
- * Usage in Next.js API route:
9
- * const { user, error } = await requireAdmin(supabase, request)
10
- * if (error) return new Response(error, { status: 403 })
11
- */
12
- export async function requireAdmin(
13
- supabase: SupabaseClient,
14
- request: Request
15
- ): Promise<{ user: any; error: string | null }> {
4
+ export function createAdminClient(
5
+ url?: string,
6
+ serviceRoleKey?: string
7
+ ): SupabaseClient {
8
+ const u = url || process.env.NEXT_PUBLIC_SUPABASE_URL!
9
+ const k = serviceRoleKey || process.env.SUPABASE_SERVICE_ROLE_KEY
10
+ if (!k) throw new Error("SUPABASE_SERVICE_ROLE_KEY not configured")
11
+ return createClient(u, k, {
12
+ auth: { autoRefreshToken: false, persistSession: false },
13
+ })
14
+ }
15
+
16
+ export async function requireAdmin(request: Request): Promise<any | null> {
16
17
  const authHeader = request.headers.get("authorization")
17
- if (!authHeader) {
18
- return { user: null, error: "Not authenticated" }
19
- }
18
+ if (!authHeader?.startsWith("Bearer ")) return null
20
19
 
21
- const token = authHeader.replace("Bearer ", "")
22
- const {
23
- data: { user },
24
- error,
25
- } = await supabase.auth.getUser(token)
20
+ const token = authHeader.slice(7)
21
+ const supabase = createClient(
22
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
23
+ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
24
+ )
26
25
 
27
- if (error || !user) {
28
- return { user: null, error: "Invalid session" }
29
- }
26
+ const { data: { user }, error } = await supabase.auth.getUser(token)
27
+ if (error || !user) return null
30
28
 
31
- const isAdmin = user.app_metadata?.is_admin === true
32
- if (!isAdmin) {
33
- return { user: null, error: "Forbidden" }
34
- }
29
+ const role = user.user_metadata?.role || user.app_metadata?.role
30
+ if (role !== "admin" && user.app_metadata?.is_admin !== true) return null
35
31
 
36
- return { user, error: null }
32
+ return user
37
33
  }
38
34
 
39
- /**
40
- * Invite a user by email using the service-role client.
41
- * Sets redirect to /auth/confirm for M365 safety.
42
- */
43
35
  export async function inviteUser(
44
- serviceClient: SupabaseClient,
36
+ adminClient: SupabaseClient,
45
37
  email: string,
46
38
  siteUrl: string,
47
39
  metadata?: Record<string, any>
48
40
  ) {
49
- return serviceClient.auth.admin.inviteUserByEmail(email, {
41
+ return adminClient.auth.admin.inviteUserByEmail(email, {
50
42
  redirectTo: `${siteUrl}/auth/confirm`,
51
43
  data: metadata,
52
44
  })
package/src/index.ts CHANGED
@@ -2,5 +2,7 @@ export { Login } from "./login"
2
2
  export { AuthCallback } from "./callback"
3
3
  export { AuthConfirm } from "./confirm"
4
4
  export { createBrowserClient, createServiceClient } from "./clients"
5
- export { requireAdmin, inviteUser } from "./admin"
5
+ export { createAdminClient, requireAdmin, inviteUser } from "./admin"
6
+ export { AdminPanel } from "./admin-panel"
7
+ export { handleListUsers, handleInviteUser, handleUpdateUser, handleDeleteUser } from "./admin-routes"
6
8
  export { createAuthMiddleware } from "./middleware"