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
125 changes: 125 additions & 0 deletions app/(dashboard)/dashboard/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import {
RiLoader4Line,
RiCheckLine,
RiAlertLine,
RiKeyLine,
RiEyeLine,
RiEyeOffLine,
RiFileCopyLine,
} from "@remixicon/react";
import { cn } from "@/lib/utils";
import { useSelectedServer } from "@/lib/server-context";
Expand Down Expand Up @@ -131,6 +135,13 @@ export default function SettingsPage() {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [deleting, setDeleting] = useState(false);

// Token state
const [token, setToken] = useState<string | null>(null);
const [tokenLoading, setTokenLoading] = useState(false);
const [tokenError, setTokenError] = useState<string | null>(null);
const [showToken, setShowToken] = useState(false);
const [copied, setCopied] = useState(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.

Token state persists when switching between servers

The token-related state (token, tokenError, showToken, copied) is not reset when serverId changes. When a user reveals a token for one server, then switches to a different server via the sidebar, the previous server's token remains displayed. This could cause users to copy and use the wrong server's authentication token. Other pages like the modules page properly reset their state when the selected server changes.

Additional Locations (1)

Fix in Cursor Fix in Web


// Track if settings have been modified
const [hasChanges, setHasChanges] = useState(false);
const [originalSettings, setOriginalSettings] = useState<ServerSettings | null>(null);
Expand Down Expand Up @@ -167,6 +178,39 @@ export default function SettingsPage() {
loadSettings();
}, [loadSettings]);

// Load token
const loadToken = useCallback(async () => {
if (!serverId) return;

setTokenLoading(true);
setTokenError(null);
try {
const res = await fetch(`/api/servers/${serverId}/token`);
const data = await res.json();
if (data.ok) {
setToken(data.token);
} else {
setTokenError(data.message || data.error || "Failed to load token");
}
} catch (err) {
setTokenError(err instanceof Error ? err.message : "Failed to load token");
} finally {
setTokenLoading(false);
}
}, [serverId]);

// Copy token to clipboard
const handleCopyToken = async () => {
if (!token) return;
try {
await navigator.clipboard.writeText(token);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy token:", err);
}
};

// Check for changes
useEffect(() => {
if (!originalSettings) {
Expand Down Expand Up @@ -331,6 +375,87 @@ export default function SettingsPage() {
)}
</div>

{/* Authentication Token Section */}
<div className="rounded-lg border border-[rgb(var(--border))] surface-1">
<div className="flex items-start gap-3 p-4 border-b border-[rgb(var(--border))]">
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-indigo-500/10">
<RiKeyLine className="h-4 w-4 text-indigo-500" />
</div>
<div>
<h3 className="text-sm font-medium text-[rgb(var(--foreground))]">Authentication Token</h3>
<p className="text-xs text-[rgb(var(--foreground-tertiary))]">
Use this token to link your plugin to this dashboard
</p>
</div>
</div>
<div className="p-4 space-y-3">
{token === null && !tokenLoading && !tokenError && (
<button
onClick={loadToken}
className="rounded-md border border-[rgb(var(--border))] px-3 py-1.5 text-xs font-medium text-[rgb(var(--foreground-secondary))] hover:border-[rgb(var(--border-elevated))] transition-colors"
>
Reveal Token
</button>
)}
{tokenLoading && (
<div className="flex items-center gap-2 text-sm text-[rgb(var(--foreground-secondary))]">
<RiLoader4Line className="h-4 w-4 animate-spin" />
Loading token...
</div>
)}
{tokenError && (
<div className="flex items-center gap-2 text-sm text-amber-400">
<RiAlertLine className="h-4 w-4" />
{tokenError}
</div>
)}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No retry option after token load failure

When token loading fails and tokenError is set, the "Reveal Token" button becomes hidden because the condition token === null && !tokenLoading && !tokenError evaluates to false. The error message is displayed, but users have no way to retry the request without refreshing the entire page. The !tokenError part of the condition prevents the button from appearing when an error exists.

Fix in Cursor Fix in Web

{token && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="flex-1 relative">
<input
type={showToken ? "text" : "password"}
value={token}
readOnly
className="w-full rounded-md border border-[rgb(var(--border))] surface-2 px-3 py-2 text-sm text-[rgb(var(--foreground))] font-mono focus:outline-none"
/>
</div>
<button
onClick={() => setShowToken(!showToken)}
className="rounded-md border border-[rgb(var(--border))] p-2 text-[rgb(var(--foreground-secondary))] hover:border-[rgb(var(--border-elevated))] transition-colors"
title={showToken ? "Hide token" : "Show token"}
>
{showToken ? (
<RiEyeOffLine className="h-4 w-4" />
) : (
<RiEyeLine className="h-4 w-4" />
)}
</button>
<button
onClick={handleCopyToken}
className={cn(
"rounded-md border border-[rgb(var(--border))] p-2 transition-colors",
copied
? "text-green-400 border-green-500/30"
: "text-[rgb(var(--foreground-secondary))] hover:border-[rgb(var(--border-elevated))]"
)}
title="Copy token"
>
{copied ? (
<RiCheckLine className="h-4 w-4" />
) : (
<RiFileCopyLine className="h-4 w-4" />
)}
</button>
</div>
<p className="text-xs text-[rgb(var(--foreground-muted))]">
Copy this token to your plugin&apos;s configuration file to link it to this server.
</p>
</div>
)}
</div>
</div>

{/* Webhook Section */}
<div className="rounded-lg border border-[rgb(var(--border))] surface-1">
<div className="flex items-start gap-3 p-4 border-b border-[rgb(var(--border))]">
Expand Down
49 changes: 49 additions & 0 deletions app/api/servers/[id]/token/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";
import { createAdminClient } from "@/lib/supabase/admin";

interface RouteParams {
params: Promise<{ id: string }>;
}

export async function GET(_req: Request, { params }: RouteParams) {
const { id: serverId } = await params;

const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();

if (!user) {
return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 });
}

const admin = createAdminClient();

const { data: server, error } = await admin
.from("servers")
.select("id,owner_user_id,auth_token")
.eq("id", serverId)
.maybeSingle();

if (error) {
return NextResponse.json({ ok: false, error: error.message }, { status: 500 });
}

if (!server) {
return NextResponse.json({ ok: false, error: "server_not_found" }, { status: 404 });
}

if (server.owner_user_id !== user.id) {
return NextResponse.json({ ok: false, error: "not_owner" }, { status: 403 });
}

if (!server.auth_token) {
return NextResponse.json(
{ ok: false, error: "token_not_available", message: "Token was not stored for this server. Re-link the server to store the token." },
{ status: 404 }
);
}

return NextResponse.json({ ok: true, token: server.auth_token });
}
1 change: 1 addition & 0 deletions app/api/servers/register/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export async function POST(req: Request) {
const update: Record<string, unknown> = {
owner_user_id: user.id,
registered_at: new Date().toISOString(),
auth_token: token, // Store plain token so user can retrieve it later
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Authentication tokens stored in plaintext in database

Authentication tokens are stored in plaintext in the auth_token column. While the system already uses auth_token_hash for server lookups, storing the raw token means a database breach would expose all server authentication credentials. An attacker with database access could impersonate any registered server. Consider whether the token retrieval feature justifies this risk, or explore alternatives like encrypted storage with a separate key.

Additional Locations (1)

Fix in Cursor Fix in Web

};

// Only set name if the server doesn't already have one.
Expand Down