fix(workspace-panel): resolve workspace via /api/workspaces, not URL slug
The panel was fetching /api/workspaces/{urlSlug} where {urlSlug}
is whatever is in the `[workspace]` dynamic segment (e.g.
"mark-account"). That slug has nothing to do with vibn_workspaces.slug,
which is derived from the user's email — so the fetch 404'd, the
component showed "Loading workspace…" forever, and minting/revoking
would target a non-existent workspace.
Now:
- GET /api/workspaces lazy-creates a workspace row if the signed-in
user has none (migration path for accounts created before the
signIn hook was added).
- WorkspaceKeysPanel discovers the user's actual workspace from that
list and uses *its* slug for all subsequent calls (details, keys,
provisioning, revocation).
- Empty / error states render a proper card with a retry button
instead of a bare "Workspace not found." line.
Made-with: Cursor
This commit is contained in:
@@ -9,11 +9,10 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { authSession } from '@/lib/auth/session-server';
|
import { authSession } from '@/lib/auth/session-server';
|
||||||
import { queryOne } from '@/lib/db-postgres';
|
import { queryOne } from '@/lib/db-postgres';
|
||||||
import { listWorkspacesForUser } from '@/lib/workspaces';
|
import { ensureWorkspaceForUser, listWorkspacesForUser } from '@/lib/workspaces';
|
||||||
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
// API-key clients are pinned to one workspace
|
|
||||||
if (request.headers.get('authorization')?.toLowerCase().startsWith('bearer vibn_sk_')) {
|
if (request.headers.get('authorization')?.toLowerCase().startsWith('bearer vibn_sk_')) {
|
||||||
const principal = await requireWorkspacePrincipal(request);
|
const principal = await requireWorkspacePrincipal(request);
|
||||||
if (principal instanceof NextResponse) return principal;
|
if (principal instanceof NextResponse) return principal;
|
||||||
@@ -33,7 +32,23 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json({ workspaces: [] });
|
return NextResponse.json({ workspaces: [] });
|
||||||
}
|
}
|
||||||
|
|
||||||
const list = await listWorkspacesForUser(userRow.id);
|
// Migration path: users who signed in before the signIn hook was
|
||||||
|
// added (or before vibn_workspaces existed) have no row yet. Create
|
||||||
|
// one on first list so the UI never shows an empty state for them.
|
||||||
|
let list = await listWorkspacesForUser(userRow.id);
|
||||||
|
if (list.length === 0) {
|
||||||
|
try {
|
||||||
|
await ensureWorkspaceForUser({
|
||||||
|
userId: userRow.id,
|
||||||
|
email: session.user.email,
|
||||||
|
displayName: session.user.name ?? null,
|
||||||
|
});
|
||||||
|
list = await listWorkspacesForUser(userRow.id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[api/workspaces] lazy ensure failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ workspaces: list.map(serializeWorkspace) });
|
return NextResponse.json({ workspaces: list.map(serializeWorkspace) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,10 +68,18 @@ interface MintedKey {
|
|||||||
const APP_BASE =
|
const APP_BASE =
|
||||||
typeof window !== "undefined" ? window.location.origin : "https://vibnai.com";
|
typeof window !== "undefined" ? window.location.origin : "https://vibnai.com";
|
||||||
|
|
||||||
export function WorkspaceKeysPanel({ workspaceSlug }: { workspaceSlug: string }) {
|
/**
|
||||||
|
* The URL param (e.g. `/mark-account/settings`) is treated as a hint —
|
||||||
|
* the actual workspace is resolved via `GET /api/workspaces`, which is
|
||||||
|
* keyed off the signed-in user. That endpoint also lazy-creates the
|
||||||
|
* workspace row for users who signed in before the workspace hook
|
||||||
|
* landed, so this panel never gets stuck on a missing row.
|
||||||
|
*/
|
||||||
|
export function WorkspaceKeysPanel({ workspaceSlug: _urlHint }: { workspaceSlug?: string }) {
|
||||||
const [workspace, setWorkspace] = useState<WorkspaceSummary | null>(null);
|
const [workspace, setWorkspace] = useState<WorkspaceSummary | null>(null);
|
||||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
const [provisioning, setProvisioning] = useState(false);
|
const [provisioning, setProvisioning] = useState(false);
|
||||||
|
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
@@ -84,31 +92,50 @@ export function WorkspaceKeysPanel({ workspaceSlug }: { workspaceSlug: string })
|
|||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setErrorMsg(null);
|
||||||
try {
|
try {
|
||||||
const [wsRes, keysRes] = await Promise.all([
|
const listRes = await fetch(`/api/workspaces`, { credentials: "include" });
|
||||||
fetch(`/api/workspaces/${workspaceSlug}`),
|
if (!listRes.ok) {
|
||||||
fetch(`/api/workspaces/${workspaceSlug}/keys`),
|
const body = listRes.status === 401
|
||||||
]);
|
? "Sign in to view your workspace."
|
||||||
if (wsRes.ok) setWorkspace(await wsRes.json());
|
: `Couldn't load workspace (HTTP ${listRes.status}).`;
|
||||||
|
setErrorMsg(body);
|
||||||
|
setWorkspace(null);
|
||||||
|
setKeys([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const list = (await listRes.json()) as { workspaces: WorkspaceSummary[] };
|
||||||
|
const ws = list.workspaces?.[0] ?? null;
|
||||||
|
setWorkspace(ws);
|
||||||
|
if (!ws) {
|
||||||
|
setKeys([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const keysRes = await fetch(`/api/workspaces/${ws.slug}/keys`, { credentials: "include" });
|
||||||
if (keysRes.ok) {
|
if (keysRes.ok) {
|
||||||
const j = (await keysRes.json()) as { keys: ApiKey[] };
|
const j = (await keysRes.json()) as { keys: ApiKey[] };
|
||||||
setKeys(j.keys ?? []);
|
setKeys(j.keys ?? []);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[workspace-keys] refresh failed", err);
|
console.error("[workspace-keys] refresh failed", err);
|
||||||
|
setErrorMsg(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [workspaceSlug]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
const provision = useCallback(async () => {
|
const provision = useCallback(async () => {
|
||||||
|
if (!workspace) return;
|
||||||
setProvisioning(true);
|
setProvisioning(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/workspaces/${workspaceSlug}/provision`, { method: "POST" });
|
const res = await fetch(`/api/workspaces/${workspace.slug}/provision`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
toast.success("Provisioning re-run");
|
toast.success("Provisioning re-run");
|
||||||
await refresh();
|
await refresh();
|
||||||
@@ -117,17 +144,19 @@ export function WorkspaceKeysPanel({ workspaceSlug }: { workspaceSlug: string })
|
|||||||
} finally {
|
} finally {
|
||||||
setProvisioning(false);
|
setProvisioning(false);
|
||||||
}
|
}
|
||||||
}, [workspaceSlug, refresh]);
|
}, [workspace, refresh]);
|
||||||
|
|
||||||
const createKey = useCallback(async () => {
|
const createKey = useCallback(async () => {
|
||||||
|
if (!workspace) return;
|
||||||
if (!newName.trim()) {
|
if (!newName.trim()) {
|
||||||
toast.error("Give the key a name");
|
toast.error("Give the key a name");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/workspaces/${workspaceSlug}/keys`, {
|
const res = await fetch(`/api/workspaces/${workspace.slug}/keys`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name: newName.trim() }),
|
body: JSON.stringify({ name: newName.trim() }),
|
||||||
});
|
});
|
||||||
@@ -142,15 +171,15 @@ export function WorkspaceKeysPanel({ workspaceSlug }: { workspaceSlug: string })
|
|||||||
} finally {
|
} finally {
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
}
|
}
|
||||||
}, [workspaceSlug, newName, refresh]);
|
}, [workspace, newName, refresh]);
|
||||||
|
|
||||||
const revokeKey = useCallback(async () => {
|
const revokeKey = useCallback(async () => {
|
||||||
if (!keyToRevoke) return;
|
if (!workspace || !keyToRevoke) return;
|
||||||
setRevoking(true);
|
setRevoking(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/workspaces/${workspaceSlug}/keys/${keyToRevoke.id}`,
|
`/api/workspaces/${workspace.slug}/keys/${keyToRevoke.id}`,
|
||||||
{ method: "DELETE" }
|
{ method: "DELETE", credentials: "include" }
|
||||||
);
|
);
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
toast.success("Key revoked");
|
toast.success("Key revoked");
|
||||||
@@ -161,21 +190,39 @@ export function WorkspaceKeysPanel({ workspaceSlug }: { workspaceSlug: string })
|
|||||||
} finally {
|
} finally {
|
||||||
setRevoking(false);
|
setRevoking(false);
|
||||||
}
|
}
|
||||||
}, [workspaceSlug, keyToRevoke, refresh]);
|
}, [workspace, keyToRevoke, refresh]);
|
||||||
|
|
||||||
if (loading && !workspace) {
|
if (loading && !workspace) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 24, color: "var(--muted)", fontSize: 13 }}>
|
<section style={cardStyle}>
|
||||||
<Loader2 className="inline animate-spin" size={14} /> Loading workspace…
|
<div style={{ display: "flex", alignItems: "center", gap: 8, color: "var(--muted)", fontSize: 13 }}>
|
||||||
</div>
|
<Loader2 className="animate-spin" size={14} /> Loading workspace…
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 24, color: "var(--muted)", fontSize: 13 }}>
|
<section style={cardStyle}>
|
||||||
Workspace not found.
|
<header style={cardHeaderStyle}>
|
||||||
</div>
|
<div>
|
||||||
|
<h2 style={cardTitleStyle}>Workspace</h2>
|
||||||
|
<p style={cardSubtitleStyle}>
|
||||||
|
{errorMsg ?? "No workspace yet — this usually means you signed in before AI access was rolled out."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={refresh} disabled={loading}>
|
||||||
|
{loading ? <Loader2 className="animate-spin" size={14} /> : <RefreshCw size={14} />}
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
<p style={{ fontSize: 12.5, color: "var(--muted)", margin: 0 }}>
|
||||||
|
Try signing out and back in, then refresh this page. If the problem
|
||||||
|
persists, the API may be down or your account may need to be
|
||||||
|
provisioned manually.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user