Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions apps/editor/app/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
'use client'

import { useState } from 'react'
import { AuditLogTab } from '@/components/admin/audit-log-tab'
import { CustomFieldsTab } from '@/components/admin/custom-fields-tab'
import { GroupsTab } from '@/components/admin/groups-tab'
import { PermissionsTab } from '@/components/admin/permissions-tab'
import { UsersTab } from '@/components/admin/users-tab'

const TABS = [
{ id: 'users', label: 'Users' },
{ id: 'groups', label: 'Groups' },
{ id: 'permissions', label: 'Permissions' },
{ id: 'custom-fields', label: 'Custom Fields' },
{ id: 'audit-log', label: 'Audit Log' },
] as const

type TabId = (typeof TABS)[number]['id']

export default function AdminPage() {
const [activeTab, setActiveTab] = useState<TabId>('users')

return (
<div className="min-h-screen bg-background text-foreground">
<div className="mx-auto max-w-6xl px-6 py-8">
<div className="mb-8">
<h1 className="text-2xl font-semibold">Admin Panel</h1>
<p className="mt-1 text-muted-foreground text-sm">
Manage users, groups, permissions, custom fields, and audit logs.
</p>
</div>

{/* Tab bar */}
<div className="mb-6 flex gap-1 border-b border-border/60">
{TABS.map((tab) => (
<button
className={`px-4 py-2.5 text-sm font-medium transition-colors ${
activeTab === tab.id
? 'border-b-2 border-foreground text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
key={tab.id}
onClick={() => setActiveTab(tab.id)}
type="button"
>
{tab.label}
</button>
))}
</div>

{/* Tab content */}
<div>
{activeTab === 'users' && <UsersTab />}
{activeTab === 'groups' && <GroupsTab />}
{activeTab === 'permissions' && <PermissionsTab />}
{activeTab === 'custom-fields' && <CustomFieldsTab />}
{activeTab === 'audit-log' && <AuditLogTab />}
</div>
</div>
</div>
)
}
25 changes: 25 additions & 0 deletions apps/editor/app/client-bootstrap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
// idempotent under HMR.
import '../lib/bootstrap'
import { type ReactNode, useEffect } from 'react'
import { useAuth } from '@/store/use-auth'
import { usePermissions } from '@/store/use-permissions'
import { getSupabaseClient } from '@/lib/supabase'

export function ClientBootstrap({ children }: { children: ReactNode }) {
useEffect(() => {
Expand All @@ -19,5 +22,27 @@ export function ClientBootstrap({ children }: { children: ReactNode }) {
// is already a direct dep, so we don't need the CDN auto-global.
import('react-scan').then(({ scan }) => scan({ enabled: true }))
}, [])

useEffect(() => {
// Initialize auth session and subscribe to changes.
useAuth.getState().init()

// When auth state changes, reload permissions for the user's groups.
const sb = getSupabaseClient()
const { data: { subscription } } = sb.auth.onAuthStateChange(async (event, session) => {
if (session?.user) {
const { data: memberRows } = await sb
.from('group_members')
.select('group_id')
.eq('user_id', session.user.id)
const groupIds = (memberRows ?? []).map((r) => r.group_id)
await usePermissions.getState().loadForGroups(groupIds)
} else {
usePermissions.getState().clear()
}
})
return () => { subscription.unsubscribe() }
}, [])

return children
}
97 changes: 97 additions & 0 deletions apps/editor/app/login/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
'use client'

import { useRouter, useSearchParams } from 'next/navigation'
import { Suspense, useState } from 'react'
import { useAuth } from '@/store/use-auth'

function LoginForm() {
const router = useRouter()
const params = useSearchParams()
const signIn = useAuth((s) => s.signIn)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)

const next = params.get('next') ?? '/'

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError(null)
const { error } = await signIn(email, password)
if (error) {
setError(error)
setLoading(false)
} else {
router.replace(next)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Login success leaves loading set

Low Severity

After a successful signIn, the form calls router.replace(next) but never clears loading. If navigation is slow or fails, the submit button stays disabled and shows Signing in….

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3d8be30. Configure here.

}

return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-sm rounded-2xl border border-border/60 bg-background p-8 shadow-xl">
<div className="mb-8 text-center">
<h1 className="font-bold text-2xl text-foreground">Sign in</h1>
<p className="mt-1 text-muted-foreground text-sm">Enter your credentials to continue</p>
</div>

<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-1.5">
<label className="font-medium text-foreground text-xs" htmlFor="email">
Email
</label>
<input
autoComplete="email"
className="rounded-lg border border-border/60 bg-accent/30 px-3 py-2 text-foreground text-sm placeholder:text-muted-foreground focus:border-foreground/30 focus:outline-none focus:ring-1 focus:ring-foreground/20"
id="email"
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
type="email"
value={email}
/>
</div>

<div className="flex flex-col gap-1.5">
<label className="font-medium text-foreground text-xs" htmlFor="password">
Password
</label>
<input
autoComplete="current-password"
className="rounded-lg border border-border/60 bg-accent/30 px-3 py-2 text-foreground text-sm placeholder:text-muted-foreground focus:border-foreground/30 focus:outline-none focus:ring-1 focus:ring-foreground/20"
id="password"
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
type="password"
value={password}
/>
</div>

{error && (
<p className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-red-400 text-xs">
{error}
</p>
)}

<button
className="mt-2 rounded-lg bg-foreground px-4 py-2.5 font-semibold text-background text-sm transition-opacity hover:opacity-80 disabled:opacity-50"
disabled={loading}
type="submit"
>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
</div>
)
}

export default function LoginPage() {
return (
<Suspense>
<LoginForm />
</Suspense>
)
}
145 changes: 145 additions & 0 deletions apps/editor/components/admin/audit-log-tab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
'use client'

import { useEffect, useState } from 'react'
import { getSupabaseClient } from '@/lib/supabase'
import type { AuditLogEntry } from '@/lib/supabase-types'

const PAGE_SIZE = 50

export function AuditLogTab() {
const [entries, setEntries] = useState<AuditLogEntry[]>([])
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState({ sceneId: '', userId: '', nodeKind: '', action: '' })
const [page, setPage] = useState(0)
const [hasMore, setHasMore] = useState(false)

const load = async (reset = true) => {
setLoading(true)
const sb = getSupabaseClient()
let q = sb
.from('audit_log')
.select('*')
.order('created_at', { ascending: false })
.range(reset ? 0 : page * PAGE_SIZE, (reset ? 0 : page) * PAGE_SIZE + PAGE_SIZE - 1)

if (filter.sceneId) q = q.eq('scene_id', filter.sceneId)
if (filter.userId) q = q.eq('user_id', filter.userId)
if (filter.nodeKind) q = q.eq('node_kind', filter.nodeKind)
if (filter.action) q = q.eq('action', filter.action as AuditLogEntry['action'])

const { data } = await q
const rows = data ?? []
if (reset) {
setEntries(rows)
setPage(0)
} else {
setEntries((prev) => [...prev, ...rows])
}
setHasMore(rows.length === PAGE_SIZE)
setLoading(false)
}

useEffect(() => { load(true) }, [filter])

const formatValue = (v: AuditLogEntry['old_value']): string => {
if (v === null || v === undefined) return '—'
if (typeof v === 'object') return JSON.stringify(v)
return String(v)
}

const actionColor = (action: string) => {
if (action === 'create') return 'text-green-400'
if (action === 'delete') return 'text-red-400'
return 'text-blue-400'
}

return (
<div className="flex flex-col gap-4">
{/* Filters */}
<div className="flex flex-wrap gap-3">
{[
{ key: 'sceneId', placeholder: 'Scene ID' },
{ key: 'nodeKind', placeholder: 'Node kind (e.g. wall)' },
{ key: 'userId', placeholder: 'User ID' },
].map(({ key, placeholder }) => (
<input
className="rounded-lg border border-border/50 bg-accent/20 px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-foreground/20"
key={key}
onChange={(e) => setFilter((prev) => ({ ...prev, [key]: e.target.value }))}
placeholder={placeholder}
value={filter[key as keyof typeof filter]}
/>
))}
<select
className="rounded-lg border border-border/50 bg-accent/20 px-3 py-1.5 text-sm"
onChange={(e) => setFilter((prev) => ({ ...prev, action: e.target.value }))}
value={filter.action}
>
<option value="">All actions</option>
<option value="create">Create</option>
<option value="update">Update</option>
<option value="delete">Delete</option>
</select>
</div>

{loading && entries.length === 0 ? (
<div className="py-8 text-center text-muted-foreground text-sm">Loading…</div>
) : entries.length === 0 ? (
<div className="py-8 text-center text-muted-foreground text-sm">No entries found.</div>
) : (
<div className="overflow-x-auto rounded-xl border border-border/60">
<table className="w-full text-xs">
<thead>
<tr className="border-border/60 border-b bg-accent/20 text-muted-foreground">
<th className="px-3 py-2.5 text-left">When</th>
<th className="px-3 py-2.5 text-left">Who</th>
<th className="px-3 py-2.5 text-left">Action</th>
<th className="px-3 py-2.5 text-left">Node</th>
<th className="px-3 py-2.5 text-left">Field</th>
<th className="px-3 py-2.5 text-left">Before</th>
<th className="px-3 py-2.5 text-left">After</th>
</tr>
</thead>
<tbody>
{entries.map((e) => (
<tr className="border-border/40 border-b last:border-0 hover:bg-accent/10" key={e.id}>
<td className="px-3 py-2.5 text-muted-foreground whitespace-nowrap">
{new Date(e.created_at).toLocaleString()}
</td>
<td className="px-3 py-2.5 font-medium">{e.user_name}</td>
<td className={`px-3 py-2.5 font-semibold uppercase tracking-wide ${actionColor(e.action)}`}>
{e.action}
</td>
<td className="px-3 py-2.5">
<span className="font-mono">{e.node_kind}</span>
<span className="ml-1 text-muted-foreground">{e.node_id.slice(0, 8)}…</span>
</td>
<td className="px-3 py-2.5 font-mono text-muted-foreground">{e.field_key ?? '—'}</td>
<td className="max-w-[160px] truncate px-3 py-2.5 text-muted-foreground">
{formatValue(e.old_value)}
</td>
<td className="max-w-[160px] truncate px-3 py-2.5">
{formatValue(e.new_value)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}

{hasMore && (
<button
className="rounded-lg border border-border/60 px-4 py-2 text-sm text-muted-foreground hover:border-border hover:text-foreground"
onClick={() => {
setPage((p) => p + 1)
load(false)
}}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Load more duplicates audit rows

Medium Severity

Clicking "Load more" calls setPage and then load(false). Because setPage updates state asynchronously, load reads the old page value, causing it to refetch and append the same page of data.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3d8be30. Configure here.

type="button"
>
Load more
</button>
)}
</div>
)
}
Loading