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
5 changes: 5 additions & 0 deletions e2e/global-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ export default async function globalSetup() {
const env = buildEnv();
Object.assign(process.env, env);

// clone the throwaway remote here: without it the seed writes JSON into a directory that has
// no `.git`, and the dev server later fails to clone into that non-empty path
const { startGitDb } = await import('../src/lib/server/gitdb');
await startGitDb();

const { seedAll } = await import('./fixtures/seed');
const seedOutput = await seedAll();
writeFileSync(SEED_OUTPUT_PATH, JSON.stringify(seedOutput, null, 2));
Expand Down
11 changes: 5 additions & 6 deletions e2e/specs/sidebar-visibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,17 +51,16 @@ test('a read-only project role has no sidebar link into Vault, Code Report or St
await expect(sidebar.locator(`a[href^="${base}/state-iac"]`)).toHaveCount(0);
});

test('project admin has sidebar links into Vault, Code Report and State IaC', async ({
page,
loginAs,
}) => {
test('project admin has a sidebar link into Code Report', async ({ page, loginAs }) => {
await loginAs('projectAdmin');
await page.goto(`/org/${org}/projects/${project}/settings/overview`);
const sidebar = page.locator('aside').first();
const base = `/org/${org}/projects/${project}`;
await expect(sidebar.locator(`a[href="${base}/vault"]`)).toBeVisible();
// Code Report has multiple sub-items, so it renders as a collapsible group (button, not a
// direct link) — its label being present is enough to prove the module itself is visible.
await expect(sidebar.getByText('Code Report', { exact: true })).toBeVisible();
await expect(sidebar.locator(`a[href^="${base}/state-iac"]`).first()).toBeVisible();
// Vault and State IaC are "coming soon": the service forces both modules off, so they never
// reach the sidebar regardless of permissions.
await expect(sidebar.locator(`a[href="${base}/vault"]`)).toHaveCount(0);
await expect(sidebar.locator(`a[href^="${base}/state-iac"]`)).toHaveCount(0);
});
14 changes: 14 additions & 0 deletions e2e/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"target": "esnext",
"paths": {
"$lib": ["../src/lib"],
"$lib/*": ["../src/lib/*"],
"$modules": ["../src/modules"],
"$modules/*": ["../src/modules/*"],
"$env/dynamic/private": ["../test/env-dynamic-private.ts"]
}
}
}
2 changes: 1 addition & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export default [
js.configs.recommended,
...svelte.configs['flat/recommended'],
{
ignores: ['.svelte-kit/**', 'build/**', 'dist/**'],
ignores: ['.svelte-kit/**', 'build/**', 'dist/**', 'src/lib/paraglide/**'],
},
{
files: ['**/*.{js,ts}'],
Expand Down
2 changes: 2 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { E2E_BASE_URL } from './e2e/global-setup';

export default defineConfig({
testDir: './e2e/specs',
// app modules imported by the seed use SvelteKit aliases ($lib, $modules, $env) that only this tsconfig maps
tsconfig: './e2e/tsconfig.json',
fullyParallel: false,
workers: 1,
retries: 1,
Expand Down
2 changes: 1 addition & 1 deletion src/lib/i18n/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { browser } from '$app/environment';
import { writable, type Readable } from 'svelte/store';
import * as messages from '$lib/paraglide/messages.js';
import { getLocale, setLocale as paraglideSetLocale } from '$lib/paraglide/runtime.js';
import { setLocale as paraglideSetLocale } from '$lib/paraglide/runtime.js';

const LOCALE_KEY = 'gitops-locale';
export const SUPPORTED_LOCALES = ['es', 'en'] as const;
Expand Down
26 changes: 21 additions & 5 deletions src/lib/server/infra/notifications/template.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';

export type TemplateVariables = Record<string, string | number | boolean | null | undefined>;

const TEMPLATES_ROOT = path.resolve(process.cwd(), 'src/notifications/email');
const TEMPLATE_NAME = /^[a-zA-Z0-9_-]+$/;
const TEMPLATES_DIR = 'src/notifications/email';

// Vite inlines this at build time so templates ship with the server bundle; outside Vite
// (plain Node runners such as Playwright's global setup) it throws and we read from disk.
let bundledTemplates: Record<string, string> = {};
try {
bundledTemplates = import.meta.glob(`/src/notifications/email/*.html`, {
query: '?raw',
import: 'default',
eager: true,
}) as Record<string, string>;
} catch {
bundledTemplates = {};
}

const cache = new Map<string, string>();

Expand Down Expand Up @@ -43,13 +53,19 @@ export async function renderTemplate(

let template = cache.get(name);
if (template === undefined) {
template = await readFile(path.join(TEMPLATES_ROOT, `${name}.html`), 'utf8');
template = bundledTemplates[`/${TEMPLATES_DIR}/${name}.html`] ?? (await readFromDisk(name));
cache.set(name, template);
}

return renderTemplateString(template, variables);
}

async function readFromDisk(name: string): Promise<string> {
const { readFile } = await import('node:fs/promises');
const path = await import('node:path');
return readFile(path.join(process.cwd(), TEMPLATES_DIR, `${name}.html`), 'utf8');
}

export function clearTemplateCache(): void {
cache.clear();
}
97 changes: 20 additions & 77 deletions src/routes/org/[org]/settings/global/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,7 @@
import { _ } from '$lib/i18n';

let googleSsoEnabled = false;
let googleClientId = '';
let googleClientSecret = '';

let samlEnabled = false;
let samlEntryPoint = '';
let samlIssuer = '';
let samlCert = '';

let configError = '';
let isSaving = false;
Expand Down Expand Up @@ -60,109 +54,58 @@
</div>
</div>

<section class="overflow-hidden rounded-md border border-slate-200 bg-white">
<div class="flex items-start gap-3 border-b border-slate-200 px-4 py-4">
<section class="overflow-hidden rounded-md border border-slate-200 bg-white opacity-75">
<div class="flex items-start gap-3 px-4 py-4">
<input
id="google-sso"
type="checkbox"
disabled
bind:checked={googleSsoEnabled}
class="mt-0.5 h-4 w-4 rounded border-slate-300 text-slate-900 focus:ring-slate-400"
class="mt-0.5 h-4 w-4 rounded border-slate-300 text-slate-900 focus:ring-slate-400 disabled:cursor-not-allowed"
/>
<div class="flex-1">
<label
for="google-sso"
class="flex cursor-pointer items-center gap-2 text-sm font-semibold text-slate-900"
class="flex items-center gap-2 text-sm font-semibold text-slate-900"
>
<Building2 class="h-4 w-4" />
GCP Workspace (Google SSO)
<span
class="inline-flex items-center rounded-full bg-amber-50 px-2.5 py-0.5 text-xs font-medium text-amber-800"
>
{$_('common.comingSoon')}
</span>
</label>
<p class="mt-1 text-sm text-slate-600">{$_('orgSettings.global.googleDescription')}</p>
</div>
</div>

{#if googleSsoEnabled}
<div class="space-y-4 bg-slate-50 px-4 py-4">
<div>
<label for="google-client-id" class="block text-sm font-medium text-slate-700">{$_('orgSettings.global.googleClientId')}</label>
<input
id="google-client-id"
type="text"
bind:value={googleClientId}
class="mt-2 w-full rounded-md border border-slate-200 bg-white px-4 py-2.5 text-sm outline-none transition focus:border-slate-400 focus:ring-2 focus:ring-slate-200"
placeholder="123456789-abc.apps.googleusercontent.com"
/>
</div>

<div>
<label for="google-client-secret" class="block text-sm font-medium text-slate-700">{$_('orgSettings.global.googleClientSecret')}</label>
<input
id="google-client-secret"
type="password"
bind:value={googleClientSecret}
class="mt-2 w-full rounded-md border border-slate-200 bg-white px-4 py-2.5 text-sm outline-none transition focus:border-slate-400 focus:ring-2 focus:ring-slate-200"
placeholder="********"
/>
</div>
</div>
{/if}
</section>

<section class="overflow-hidden rounded-md border border-slate-200 bg-white">
<div class="flex items-start gap-3 border-b border-slate-200 px-4 py-4">
<section class="overflow-hidden rounded-md border border-slate-200 bg-white opacity-75">
<div class="flex items-start gap-3 px-4 py-4">
<input
id="saml-enabled"
type="checkbox"
disabled
bind:checked={samlEnabled}
class="mt-0.5 h-4 w-4 rounded border-slate-300 text-slate-900 focus:ring-slate-400"
class="mt-0.5 h-4 w-4 rounded border-slate-300 text-slate-900 focus:ring-slate-400 disabled:cursor-not-allowed"
/>
<div class="flex-1">
<label
for="saml-enabled"
class="flex cursor-pointer items-center gap-2 text-sm font-semibold text-slate-900"
class="flex items-center gap-2 text-sm font-semibold text-slate-900"
>
<KeyRound class="h-4 w-4" />
SAML
<span
class="inline-flex items-center rounded-full bg-amber-50 px-2.5 py-0.5 text-xs font-medium text-amber-800"
>
{$_('common.comingSoon')}
</span>
</label>
<p class="mt-1 text-sm text-slate-600">{$_('orgSettings.global.samlDescription')}</p>
</div>
</div>

{#if samlEnabled}
<div class="space-y-4 bg-slate-50 px-4 py-4">
<div>
<label for="saml-entry" class="block text-sm font-medium text-slate-700">{$_('orgSettings.global.samlEntryPoint')}</label>
<input
id="saml-entry"
type="text"
bind:value={samlEntryPoint}
class="mt-2 w-full rounded-md border border-slate-200 bg-white px-4 py-2.5 text-sm outline-none transition focus:border-slate-400 focus:ring-2 focus:ring-slate-200"
placeholder="https://idp.example.com/sso"
/>
</div>

<div>
<label for="saml-issuer" class="block text-sm font-medium text-slate-700">{$_('orgSettings.global.samlIssuer')}</label>
<input
id="saml-issuer"
type="text"
bind:value={samlIssuer}
class="mt-2 w-full rounded-md border border-slate-200 bg-white px-4 py-2.5 text-sm outline-none transition focus:border-slate-400 focus:ring-2 focus:ring-slate-200"
placeholder="gitops"
/>
</div>

<div>
<label for="saml-cert" class="block text-sm font-medium text-slate-700">{$_('orgSettings.global.samlCertificate')}</label>
<textarea
id="saml-cert"
bind:value={samlCert}
rows="4"
class="mt-2 w-full rounded-md border border-slate-200 bg-white px-4 py-2.5 text-sm outline-none transition focus:border-slate-400 focus:ring-2 focus:ring-slate-200"
placeholder="-----BEGIN CERTIFICATE-----"
></textarea>
</div>
</div>
{/if}
</section>

{#if configError}
Expand Down
Loading