-
Notifications
You must be signed in to change notification settings - Fork 0
Add server token retrieval feature to dashboard #49
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"; | ||
|
|
@@ -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); | ||
|
|
||
| // Track if settings have been modified | ||
| const [hasChanges, setHasChanges] = useState(false); | ||
| const [originalSettings, setOriginalSettings] = useState<ServerSettings | null>(null); | ||
|
|
@@ -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) { | ||
|
|
@@ -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> | ||
| )} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No retry option after token load failureWhen token loading fails and |
||
| {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'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))]"> | ||
|
|
||
| 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 }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Authentication tokens stored in plaintext in databaseAuthentication tokens are stored in plaintext in the Additional Locations (1) |
||
| }; | ||
|
|
||
| // Only set name if the server doesn't already have one. | ||
|
|
||
There was a problem hiding this comment.
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 whenserverIdchanges. 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)
app/(dashboard)/dashboard/settings/page.tsx#L176-L179