Skip to content
Merged
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
18 changes: 17 additions & 1 deletion packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1100,9 +1100,25 @@ export class AuthManager {
return this.config.emailService;
}

/**
* Override the brand name surfaced in built-in auth emails (`{{appName}}`),
* sourced from the live `branding.workspace_name` setting.
*
* AuthPlugin calls this on `kernel:ready` (and again whenever the setting
* changes) once the `settings` service resolves. Passing `undefined` clears
* the override so resolution falls back to the configured `appName`. The
* value only reflects an *explicitly set* setting — when the operator has
* not customised it, AuthPlugin passes `undefined` so a deployment's
* configured `appName` (e.g. `OS_APP_NAME`) keeps precedence.
*/
setAppName(name: string | undefined): void {
this.appNameOverride = name?.trim() || undefined;
}
private appNameOverride?: string;

/** @internal `{{appName}}` placeholder value for built-in templates. */
private getAppName(): string {
return this.config.appName ?? 'ObjectStack';
return this.appNameOverride ?? this.config.appName ?? 'ObjectStack';
}

/**
Expand Down
66 changes: 65 additions & 1 deletion packages/plugins/plugin-auth/src/auth-plugin.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { AuthPlugin } from './auth-plugin';
import { AuthManager } from './auth-manager';
import type { PluginContext } from '@objectstack/core';

describe('AuthPlugin', () => {
Expand Down Expand Up @@ -385,6 +386,69 @@ describe('AuthPlugin', () => {
});
});

describe('Brand name binding (branding.workspace_name)', () => {
let hookCapture: ReturnType<typeof createHookCapture>;
let setAppNameSpy: ReturnType<typeof vi.spyOn>;

const makeSettings = (resolved: { value: unknown; source: string } | Error) => ({
get: vi.fn(async () => {
if (resolved instanceof Error) throw resolved;
return resolved;
}),
subscribe: vi.fn(),
});

const bootWithSettings = async (settings: unknown) => {
hookCapture = createHookCapture();
mockContext.hook = hookCapture.hookFn;
mockContext.getService = vi.fn((name: string) => {
if (name === 'manifest') return { register: vi.fn() };
if (name === 'settings') return settings;
// 'data', 'email', 'http-server' → absent in this harness
return undefined;
});
setAppNameSpy = vi.spyOn(AuthManager.prototype, 'setAppName');
authPlugin = new AuthPlugin({
secret: 'test-secret-at-least-32-chars-long',
baseUrl: 'http://localhost:3000',
appName: 'Configured Default',
});
await authPlugin.init(mockContext);
await authPlugin.start(mockContext);
await hookCapture.trigger('kernel:ready');
};

afterEach(() => {
setAppNameSpy?.mockRestore();
});

it('overrides appName when the setting is explicitly set', async () => {
const settings = makeSettings({ value: 'Acme Corp', source: 'global' });
await bootWithSettings(settings);

expect(settings.get).toHaveBeenCalledWith('branding', 'workspace_name', {});
expect(setAppNameSpy).toHaveBeenCalledWith('Acme Corp');
expect(settings.subscribe).toHaveBeenCalledWith('branding', expect.any(Function));
});

it('clears the override when the setting falls through to its manifest default', async () => {
const settings = makeSettings({ value: 'ObjectStack', source: 'default' });
await bootWithSettings(settings);

// source === 'default' means the operator never customised it, so the
// configured appName (e.g. OS_APP_NAME) must keep precedence.
expect(setAppNameSpy).toHaveBeenCalledWith(undefined);
});

it('does not throw when reading the setting fails', async () => {
const settings = makeSettings(new Error('boom'));
await expect(bootWithSettings(settings)).resolves.toBeUndefined();
expect(mockContext.logger.warn).toHaveBeenCalledWith(
expect.stringContaining('failed to apply branding.workspace_name'),
);
});
});

describe('Destroy Phase', () => {
it('should cleanup resources', async () => {
authPlugin = new AuthPlugin({
Expand Down
37 changes: 37 additions & 0 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,43 @@ export class AuthPlugin implements Plugin {
} catch {
ctx.logger.info('Auth: no email service registered — auth callbacks will log instead of sending');
}

// Bind the email brand name (`{{appName}}`) to the live
// `branding.workspace_name` setting so the admin UI can rename the
// product without a redeploy. Only an *explicitly set* value
// overrides the configured `appName` — when the operator hasn't
// customised it (resolver returns the manifest default), we clear
// the override so the deployment's `appName` (e.g. `OS_APP_NAME`)
// keeps precedence. Mirrors EmailServicePlugin's settings binding.
try {
const settings = ctx.getService<any>('settings');
if (settings && typeof settings.get === 'function') {
const applyBrand = async () => {
try {
const resolved = await settings.get('branding', 'workspace_name', {});
const explicit = resolved && resolved.source !== 'default'
? resolved.value
: undefined;
this.authManager?.setAppName(
typeof explicit === 'string' ? explicit : undefined,
);
} catch (err: any) {
ctx.logger.warn(
'Auth: failed to apply branding.workspace_name: ' + (err?.message ?? err),
);
}
};
await applyBrand();
if (typeof settings.subscribe === 'function') {
settings.subscribe('branding', () => {
void applyBrand();
});
ctx.logger.info('Auth: bound appName to settings namespace=branding');
}
}
} catch {
// settings service is optional — keep the configured appName.
}
}

let httpServer: IHttpServer | null = null;
Expand Down