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:
10
changelog.md
10
changelog.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user