feat(api-tokens): add ability to roll (regenerate) API token secrets and UI to display the newly generated token once

This commit is contained in:
2026-02-27 10:24:20 +00:00
parent 56f41d70b3
commit dee6897931
8 changed files with 144 additions and 2 deletions

View File

@@ -1,5 +1,15 @@
# Changelog # Changelog
## 2026-02-27 - 10.1.0 - feat(api-tokens)
add ability to roll (regenerate) API token secrets and UI to display the newly generated token once
- Server: added ApiTokenManager.rollToken(id) to regenerate a token secret, update its hash, persist it and log the action.
- Server: added opsserver handler 'rollApiToken' which requires admin identity and returns the new raw token value (shown once) or error messages.
- API: added typed request interface IReq_RollApiToken for the rollApiToken RPC.
- Web: added appstate.rollApiToken wrapper to call the new typed request.
- UI: ops-view-apitokens updated with a 'Roll' action and a modal flow to confirm rolling, call the API, refresh token list, and present the new token value to copy (token value is shown only once).
- Security: operation is admin-only and the raw token is returned only once after rolling.
## 2026-02-27 - 10.0.0 - BREAKING CHANGE(remote-ingress) ## 2026-02-27 - 10.0.0 - BREAKING CHANGE(remote-ingress)
replace tlsConfigured boolean with tlsMode ('custom' | 'acme' | 'self-signed') and compute TLS mode server-side replace tlsConfigured boolean with tlsMode ('custom' | 'acme' | 'self-signed') and compute TLS mode server-side

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '10.0.0', version: '10.1.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }

View File

@@ -122,6 +122,24 @@ export class ApiTokenManager {
return true; return true;
} }
/**
* Roll (regenerate) a token's secret while keeping its identity.
* Returns the new raw token value (shown once).
*/
public async rollToken(id: string): Promise<{ id: string; rawToken: string } | null> {
const stored = this.tokens.get(id);
if (!stored) return null;
const randomBytes = plugins.crypto.randomBytes(32);
const rawPayload = `${id}:${randomBytes.toString('base64url')}`;
const rawToken = `${TOKEN_PREFIX_STR}${rawPayload}`;
stored.tokenHash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
await this.persistToken(stored);
logger.log('info', `API token '${stored.name}' rolled (id: ${id})`);
return { id, rawToken };
}
/** /**
* Enable or disable a token. * Enable or disable a token.
*/ */

View File

@@ -77,6 +77,25 @@ export class ApiTokenHandler {
), ),
); );
// Roll API token
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RollApiToken>(
'rollApiToken',
async (dataArg) => {
await this.requireAdmin(dataArg.identity);
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!manager) {
return { success: false, message: 'Token management not initialized' };
}
const result = await manager.rollToken(dataArg.id);
if (!result) {
return { success: false, message: 'Token not found' };
}
return { success: true, tokenValue: result.rawToken };
},
),
);
// Toggle API token // Toggle API token
this.typedrouter.addTypedHandler( this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleApiToken>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleApiToken>(

View File

@@ -63,6 +63,26 @@ export interface IReq_RevokeApiToken extends plugins.typedrequestInterfaces.impl
}; };
} }
/**
* Roll (regenerate) an API token's secret. Returns the new raw token value once.
* Admin JWT only.
*/
export interface IReq_RollApiToken extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_RollApiToken
> {
method: 'rollApiToken';
request: {
identity?: authInterfaces.IIdentity;
id: string;
};
response: {
success: boolean;
tokenValue?: string;
message?: string;
};
}
/** /**
* Enable or disable an API token. * Enable or disable an API token.
*/ */

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '10.0.0', version: '10.1.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }

View File

@@ -1115,6 +1115,18 @@ export async function createApiToken(name: string, scopes: interfaces.data.TApiT
}); });
} }
export async function rollApiToken(id: string) {
const context = getActionContext();
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_RollApiToken
>('/typedrequest', 'rollApiToken');
return request.fire({
identity: context.identity,
id,
});
}
export const revokeApiTokenAction = routeManagementStatePart.createAction<string>( export const revokeApiTokenAction = routeManagementStatePart.createAction<string>(
async (statePartArg, tokenId) => { async (statePartArg, tokenId) => {
const context = getActionContext(); const context = getActionContext();

View File

@@ -152,6 +152,15 @@ export class OpsViewApiTokens extends DeesElement {
); );
}, },
}, },
{
name: 'Roll',
iconName: 'lucide:refresh-cw',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const token = actionData.item as interfaces.data.IApiTokenInfo;
await this.showRollTokenDialog(token);
},
},
{ {
name: 'Revoke', name: 'Revoke',
iconName: 'lucide:trash2', iconName: 'lucide:trash2',
@@ -279,6 +288,60 @@ export class OpsViewApiTokens extends DeesElement {
}); });
} }
private async showRollTokenDialog(token: interfaces.data.IApiTokenInfo) {
const { DeesModal } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({
heading: 'Roll Token Secret',
content: html`
<div style="color: #ccc; padding: 8px 0;">
<p>This will regenerate the secret for <strong>${token.name}</strong>. The old token value will stop working immediately.</p>
</div>
`,
menuOptions: [
{
name: 'Cancel',
iconName: 'lucide:x',
action: async (modalArg: any) => await modalArg.destroy(),
},
{
name: 'Roll Token',
iconName: 'lucide:refresh-cw',
action: async (modalArg: any) => {
await modalArg.destroy();
try {
const response = await appstate.rollApiToken(token.id);
if (response.success && response.tokenValue) {
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null);
await DeesModal.createAndShow({
heading: 'Token Rolled',
content: html`
<div style="color: #ccc; padding: 8px 0;">
<p>Copy this token now. It will not be shown again.</p>
<div style="background: #111; padding: 12px; border-radius: 6px; margin-top: 8px;">
<code style="color: #0f8; word-break: break-all; font-size: 13px;">${response.tokenValue}</code>
</div>
</div>
`,
menuOptions: [
{
name: 'Done',
iconName: 'lucide:check',
action: async (m: any) => await m.destroy(),
},
],
});
}
} catch (error) {
console.error('Failed to roll token:', error);
}
},
},
],
});
}
async firstUpdated() { async firstUpdated() {
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null); await appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null);
} }