22import { useEffect , useState , useCallback , useRef } from "react" ;
33import { useParams } from "next/navigation" ;
44import { copyText } from "@/lib/clipboard" ;
5+ import { formatApiKeyTime } from "@/lib/api-key-time" ;
56import { formatStoredWebhookPayload } from "@/lib/webhook-payload" ;
67import {
78 filterSignupsByQuery ,
@@ -11,7 +12,7 @@ import {
1112} from "@/lib/waitlist-filter" ;
1213// Tab values double as URL slugs: /dashboard/<tab> (the default "page" tab lives at
1314// bare /dashboard). Keep this in sync with the tab buttons below.
14- const TABS = [ "page" , "videos" , "waitlist" , "auctions" , "webhooks" , "affiliates" , "dns" ] as const ;
15+ const TABS = [ "page" , "videos" , "waitlist" , "auctions" , "webhooks" , "keys" , " affiliates", "dns" ] as const ;
1516type Tab = ( typeof TABS ) [ number ] ;
1617
1718/**
@@ -132,6 +133,7 @@ export default function Dashboard() {
132133 < button className = { `tab${ tab === "waitlist" ? " on" : "" } ` } onClick = { ( ) => setTab ( "waitlist" ) } > Waitlist</ button >
133134 < button className = { `tab${ tab === "auctions" ? " on" : "" } ` } onClick = { ( ) => setTab ( "auctions" ) } > Auctions</ button >
134135 < button className = { `tab${ tab === "webhooks" ? " on" : "" } ` } onClick = { ( ) => setTab ( "webhooks" ) } > Webhooks</ button >
136+ < button className = { `tab${ tab === "keys" ? " on" : "" } ` } onClick = { ( ) => setTab ( "keys" ) } > API keys</ button >
135137 < button className = { `tab${ tab === "affiliates" ? " on" : "" } ` } onClick = { ( ) => setTab ( "affiliates" ) } > Affiliates</ button >
136138 < button className = { `tab${ tab === "dns" ? " on" : "" } ` } onClick = { ( ) => setTab ( "dns" ) } > Custom domain</ button >
137139 < a className = "tab" href = { PIT_RECORDS_URL } > Moshpit DNS ↗</ a >
@@ -143,6 +145,8 @@ export default function Dashboard() {
143145 < AffiliatesPanel onError = { ( m ) => say ( m , false ) } onOk = { ( m ) => say ( m , true ) } />
144146 ) : tab === "webhooks" ? (
145147 < DomainWebhooksPanel onError = { ( m ) => say ( m , false ) } onOk = { ( m ) => say ( m , true ) } />
148+ ) : tab === "keys" ? (
149+ < ApiKeysPanel onError = { ( m ) => say ( m , false ) } onOk = { ( m ) => say ( m , true ) } />
146150 ) : tab === "auctions" ? (
147151 < AuctionsPanel onError = { ( m ) => say ( m , false ) } onOk = { ( m ) => say ( m , true ) } />
148152 ) : tab === "videos" ? (
@@ -1234,6 +1238,140 @@ function AffiliatesPanel({ onError, onOk }: { onError: (m: string) => void; onOk
12341238 ) ;
12351239}
12361240
1241+ type ApiKeyView = {
1242+ id : string ;
1243+ name : string | null ;
1244+ prefix : string ;
1245+ created_at : string ;
1246+ last_used : string | null ;
1247+ revoked_at : string | null ;
1248+ } ;
1249+
1250+ function ApiKeysPanel ( { onError, onOk } : { onError : ( m : string ) => void ; onOk : ( m : string ) => void } ) {
1251+ const [ keys , setKeys ] = useState < ApiKeyView [ ] | undefined > ( undefined ) ;
1252+ const [ loadError , setLoadError ] = useState < string | null > ( null ) ;
1253+ const [ name , setName ] = useState ( "" ) ;
1254+ const [ created , setCreated ] = useState < { token : string ; note : string } | null > ( null ) ;
1255+ const [ busy , setBusy ] = useState ( false ) ;
1256+
1257+ const load = async ( ) : Promise < boolean > => {
1258+ setLoadError ( null ) ;
1259+ setKeys ( undefined ) ;
1260+ try {
1261+ const data = await api ( "/api/account/keys" ) ;
1262+ setKeys ( data . keys || [ ] ) ;
1263+ return true ;
1264+ } catch ( e : any ) {
1265+ const message = e . message || "Could not load API keys." ;
1266+ setLoadError ( message ) ;
1267+ onError ( message ) ;
1268+ return false ;
1269+ }
1270+ } ;
1271+
1272+ useEffect ( ( ) => {
1273+ void load ( ) ;
1274+ // Loading is tied to opening the panel; mutations refresh explicitly.
1275+ // eslint-disable-next-line react-hooks/exhaustive-deps
1276+ } , [ ] ) ;
1277+
1278+ const create = async ( ) => {
1279+ setBusy ( true ) ;
1280+ try {
1281+ const data = await api ( "/api/account/keys" , "POST" , { name : name . trim ( ) || undefined } ) ;
1282+ setCreated ( { token : data . token , note : data . note } ) ;
1283+ setName ( "" ) ;
1284+ if ( await load ( ) ) onOk ( "API key created." ) ;
1285+ } catch ( e : any ) {
1286+ onError ( e . message || "Could not create API key." ) ;
1287+ } finally {
1288+ setBusy ( false ) ;
1289+ }
1290+ } ;
1291+
1292+ const revoke = async ( key : ApiKeyView ) => {
1293+ const label = key . name || key . prefix ;
1294+ if ( ! window . confirm ( `Revoke ${ label } ? Scripts using it will stop working immediately.` ) ) return ;
1295+ setBusy ( true ) ;
1296+ try {
1297+ await api ( `/api/account/keys?id=${ encodeURIComponent ( key . id ) } ` , "DELETE" ) ;
1298+ if ( await load ( ) ) onOk ( "API key revoked." ) ;
1299+ } catch ( e : any ) {
1300+ onError ( e . message || "Could not revoke API key." ) ;
1301+ } finally {
1302+ setBusy ( false ) ;
1303+ }
1304+ } ;
1305+
1306+ return (
1307+ < section className = "card2" >
1308+ < h2 > API keys</ h2 >
1309+ < p className = "sub" > Use a key as a Bearer token from scripts and CI. New tokens are shown once and are not stored in recoverable form.</ p >
1310+
1311+ < div className = "row" >
1312+ < input
1313+ className = "inp"
1314+ maxLength = { 80 }
1315+ placeholder = "key name — e.g. deploy workflow"
1316+ value = { name }
1317+ disabled = { busy || Boolean ( created ) }
1318+ onChange = { ( e ) => setName ( e . target . value ) }
1319+ onKeyDown = { ( e ) => { if ( e . key === "Enter" && ! busy && ! created ) void create ( ) ; } }
1320+ />
1321+ < button type = "button" className = "btn2" disabled = { busy || Boolean ( created ) } onClick = { ( ) => void create ( ) } >
1322+ { busy ? "Working…" : "Create key" }
1323+ </ button >
1324+ </ div >
1325+
1326+ { created && (
1327+ < div className = "secret" role = "status" style = { { marginTop : 14 } } >
1328+ < b > Copy this key now</ b >
1329+ < p className = "sub" style = { { margin : "6px 0" } } > { created . note } </ p >
1330+ < code style = { { display : "block" , overflowWrap : "anywhere" , marginBottom : 10 } } > { created . token } </ code >
1331+ < span className = "row-actions" >
1332+ < button
1333+ type = "button"
1334+ className = "btn2"
1335+ onClick = { async ( ) => {
1336+ if ( await copyText ( created . token ) ) onOk ( "API key copied." ) ;
1337+ else onError ( "Copy failed — select the key and copy it manually." ) ;
1338+ } }
1339+ >
1340+ Copy key
1341+ </ button >
1342+ < button type = "button" className = "btn2 ghost" onClick = { ( ) => setCreated ( null ) } > Dismiss</ button >
1343+ </ span >
1344+ </ div >
1345+ ) }
1346+
1347+ < h3 className = "ed-h" > Your keys</ h3 >
1348+ { loadError ? (
1349+ < p className = "sub" role = "alert" > Could not load API keys: { loadError } </ p >
1350+ ) : keys === undefined ? (
1351+ < p className = "sub" > Loading…</ p >
1352+ ) : (
1353+ < ul className = "list" >
1354+ { keys . map ( ( key ) => (
1355+ < li key = { key . id } >
1356+ < span >
1357+ < b > { key . name || "Unnamed key" } </ b >
1358+ < span className = "muted" > · { key . prefix } … · created { formatApiKeyTime ( key . created_at ) } </ span >
1359+ < span className = "muted" > · last used { formatApiKeyTime ( key . last_used ) } </ span >
1360+ </ span >
1361+ { key . revoked_at ? (
1362+ < span className = "muted" > revoked { formatApiKeyTime ( key . revoked_at ) } </ span >
1363+ ) : (
1364+ < button type = "button" className = "btn2 ghost" disabled = { busy } onClick = { ( ) => void revoke ( key ) } > Revoke</ button >
1365+ ) }
1366+ </ li >
1367+ ) ) }
1368+ { keys . length === 0 && < li className = "muted" > No API keys yet.</ li > }
1369+ </ ul >
1370+ ) }
1371+ </ section >
1372+ ) ;
1373+ }
1374+
12371375function ProjectWebhooks ( { project, onError } : { project : Project ; onError : ( m : string ) => void } ) {
12381376 const [ out , setOut ] = useState < any [ ] > ( [ ] ) ;
12391377 const [ inb , setInb ] = useState < any [ ] > ( [ ] ) ;
0 commit comments