mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 07:41:23 +00:00
Merge pull request #9 from Dvorinka/feat/instance-admin
feat: instance administration, bootstrap admin, managed-tier OAuth gate
This commit is contained in:
36 files changed
+1729
-109
No files matched your search
@@ -29,6 +29,14 @@ BETTER_AUTH_SECRET=change-me-super-long-better-auth-secret
|
||||
BETTER_AUTH_URL=http://localhost/auth
|
||||
# Comma-separated emails promoted to auth admin on boot (Authentication → Users panel)
|
||||
AUTH_ADMIN_EMAILS=
|
||||
# The first public sign-up always becomes the instance admin. With no users the
|
||||
# login page shows only the sign-up form. After bootstrap, public sign-up is
|
||||
# closed unless SIGNUP_ENABLED=true or the admin enables it in-app
|
||||
# (Settings → Instance). In-app values override this file.
|
||||
SIGNUP_ENABLED=false
|
||||
# Managed tier flag — OAuth providers activate only when this is "true".
|
||||
# Self-hosted instances are email/password regardless of the keys below.
|
||||
PRIMORA_MANAGED=false
|
||||
COOKIE_DOMAIN=localhost
|
||||
|
||||
FRONTEND_PORT=3000
|
||||
@@ -42,6 +50,8 @@ BACKEND_PUBLIC_URL=http://localhost/api/v1
|
||||
BACKEND_INTERNAL_URL=http://backend:8080
|
||||
BACKEND_STORAGE_ROOT=/data/storage
|
||||
|
||||
# Mail defaults — each is overridable in-app (Settings → Instance). In-app
|
||||
# values win over these env vars; deleting the in-app value reverts here.
|
||||
RESEND_API_KEY=
|
||||
MAIL_FROM=Primora <[email protected]>
|
||||
SMTP_HOST=mailpit
|
||||
@@ -50,6 +60,7 @@ SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_SECURE=false
|
||||
|
||||
# OAuth keys only take effect when PRIMORA_MANAGED=true (managed tier).
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
GOOGLE_CLIENT_ID=
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
|
||||
All notable changes to Primora. Format follows [Keep a Changelog](https://keepachangelog.com/); versions follow [SemVer](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Instance administration** — `core.settings` (migration 00010) stores in-app overrides that resolve ahead of `.env` and built-in defaults. New endpoints: `GET /instance/public` (unauthenticated login-page state), `GET/PUT/DELETE /instance/settings[/key]` (platform admin only — better-auth `user.role`, not org role). Manageable keys cover public sign-up, mail transport (Resend or SMTP), and API/user rate limits. Secrets are AES-256-GCM encrypted at rest, never returned by the API, and decryptable by the auth service via the shared `PRIMORA_ENCRYPTION_KEY`.
|
||||
- **Bootstrap + sign-up gating** — the first public sign-up becomes the instance admin; afterwards public sign-up is closed unless `SIGNUP_ENABLED=true` or the admin reopens it in Settings → Instance. `databaseHooks.user.create.before` gates only `/sign-up/email`, so admin-created users and OAuth flows bypass it.
|
||||
- **Managed-tier OAuth gate** — `PRIMORA_MANAGED=true` is now required for GitHub/Google/Discord/Microsoft sign-in; self-hosted instances are email/password only. The login page hides the sign-up tab and social buttons according to `GET /instance/public`.
|
||||
- **Settings → Instance tab** — platform admins edit sign-up, mail, and rate-limit settings in-app with source badges (in-app/env/default) and per-key reset.
|
||||
|
||||
## [0.6.0] - 2026-09-21
|
||||
|
||||
### Added
|
||||
|
||||
+24
-2
@@ -61,10 +61,32 @@ The important ones:
|
||||
| `POSTGRES_*` / `DATABASE_URL` | Postgres connection |
|
||||
| `DRAGONFLY_URL` | Cache/rate-limit store |
|
||||
| `SMTP_*` / `MAIL_FROM` | Transactional email (Mailpit in dev, or Resend via `RESEND_API_KEY`) |
|
||||
| `GITHUB_CLIENT_ID` / `GOOGLE_CLIENT_ID` / `DISCORD_*` / `MICROSOFT_*` | Optional OAuth providers |
|
||||
| `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | Throttling, `0` disables |
|
||||
| `SIGNUP_ENABLED` | Open public sign-up after bootstrap — default `false`; admins override in-app |
|
||||
| `PRIMORA_MANAGED` | Managed tier flag — enables OAuth providers when `true`; self-hosted is email/password only |
|
||||
| `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | Throttling defaults — overridable in-app |
|
||||
| `VITE_DEMO_MODE` | Enable the client-side demo workspace |
|
||||
|
||||
### Instance administration
|
||||
|
||||
The **first public sign-up becomes the instance admin** — on a fresh database the
|
||||
login page only offers sign-up. After that, public sign-up is closed and the
|
||||
admin can reopen it in-app.
|
||||
|
||||
Admins get a **Settings → Instance** tab that manages the instance without a
|
||||
restart: public sign-up, mail transport (Resend or SMTP), and rate limits.
|
||||
Every setting resolves in the order **in-app value → `.env` → built-in
|
||||
default**; resetting a key reverts to env/default. Secrets (SMTP password,
|
||||
Resend key) are stored AES-256-GCM-encrypted and never displayed back.
|
||||
|
||||
API equivalents:
|
||||
|
||||
```bash
|
||||
GET /api/v1/instance/public # unauthenticated — login page state
|
||||
GET /api/v1/instance/settings # platform admin
|
||||
PUT /api/v1/instance/settings/{key} # platform admin — {"value": ...}
|
||||
DELETE /api/v1/instance/settings/{key} # platform admin — revert to env/default
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
|
||||
@@ -5,7 +5,8 @@ import { requestId } from "hono/request-id";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { createClient } from "redis";
|
||||
|
||||
import { auth, authPool, promoteAdminEmails, runAuthMigrations } from "./lib/auth.js";
|
||||
import { auth, promoteAdminEmails, runAuthMigrations } from "./lib/auth.js";
|
||||
import { authPool } from "./lib/db.js";
|
||||
import { env } from "./lib/env.js";
|
||||
|
||||
const redisClient = createClient({
|
||||
|
||||
+67
-40
@@ -1,50 +1,53 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { APIError } from "better-auth/api";
|
||||
import { admin, jwt } from "better-auth/plugins";
|
||||
import { getMigrations } from "better-auth/db/migration";
|
||||
import { Pool } from "pg";
|
||||
|
||||
import { authPool } from "./db.js";
|
||||
import { sendTransactionalEmail } from "./mail.js";
|
||||
import { getBoolSetting } from "./settings.js";
|
||||
import { env } from "./env.js";
|
||||
|
||||
export const authPool = new Pool({
|
||||
connectionString: env.DATABASE_URL,
|
||||
});
|
||||
|
||||
const socialProviders = {
|
||||
...(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET
|
||||
? {
|
||||
github: {
|
||||
clientId: env.GITHUB_CLIENT_ID,
|
||||
clientSecret: env.GITHUB_CLIENT_SECRET,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
|
||||
? {
|
||||
google: {
|
||||
clientId: env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: env.GOOGLE_CLIENT_SECRET,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET
|
||||
? {
|
||||
discord: {
|
||||
clientId: env.DISCORD_CLIENT_ID,
|
||||
clientSecret: env.DISCORD_CLIENT_SECRET,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(env.MICROSOFT_CLIENT_ID && env.MICROSOFT_CLIENT_SECRET
|
||||
? {
|
||||
microsoft: {
|
||||
clientId: env.MICROSOFT_CLIENT_ID,
|
||||
clientSecret: env.MICROSOFT_CLIENT_SECRET,
|
||||
tenantId: env.MICROSOFT_TENANT_ID,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
// OAuth providers exist only on the managed tier (PRIMORA_MANAGED=true).
|
||||
// Self-hosted instances are email/password only — the env keys are ignored
|
||||
// unless the managed flag is set.
|
||||
const socialProviders = env.PRIMORA_MANAGED
|
||||
? {
|
||||
...(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET
|
||||
? {
|
||||
github: {
|
||||
clientId: env.GITHUB_CLIENT_ID,
|
||||
clientSecret: env.GITHUB_CLIENT_SECRET,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
|
||||
? {
|
||||
google: {
|
||||
clientId: env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: env.GOOGLE_CLIENT_SECRET,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET
|
||||
? {
|
||||
discord: {
|
||||
clientId: env.DISCORD_CLIENT_ID,
|
||||
clientSecret: env.DISCORD_CLIENT_SECRET,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(env.MICROSOFT_CLIENT_ID && env.MICROSOFT_CLIENT_SECRET
|
||||
? {
|
||||
microsoft: {
|
||||
clientId: env.MICROSOFT_CLIENT_ID,
|
||||
clientSecret: env.MICROSOFT_CLIENT_SECRET,
|
||||
tenantId: env.MICROSOFT_TENANT_ID,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: {};
|
||||
|
||||
export const auth = betterAuth({
|
||||
appName: "Primora",
|
||||
@@ -63,6 +66,30 @@ export const auth = betterAuth({
|
||||
});
|
||||
},
|
||||
},
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
// Gates only the public sign-up endpoint. Users created through the
|
||||
// admin plugin or OAuth callbacks take different paths and pass.
|
||||
async before(user, ctx) {
|
||||
if (ctx?.path !== "/sign-up/email") return;
|
||||
const { rows } = await authPool.query<{ n: number }>(
|
||||
`select count(*)::int as n from "user"`,
|
||||
);
|
||||
if (rows[0].n === 0) {
|
||||
// Bootstrap: the first public signup becomes the instance admin.
|
||||
return { data: { ...user, role: "admin" } };
|
||||
}
|
||||
const enabled = await getBoolSetting("auth.signup_enabled", env.SIGNUP_ENABLED);
|
||||
if (!enabled) {
|
||||
throw new APIError("FORBIDDEN", {
|
||||
message: "Sign-up is disabled on this instance.",
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
emailVerification: {
|
||||
sendOnSignUp: true,
|
||||
autoSignInAfterVerification: true,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Pool } from "pg";
|
||||
|
||||
import { env } from "./env.js";
|
||||
|
||||
// Shared by better-auth and the instance-settings reader. Lives in its own
|
||||
// module so settings.ts can be imported by auth.ts without a cycle.
|
||||
export const authPool = new Pool({
|
||||
connectionString: env.DATABASE_URL,
|
||||
});
|
||||
@@ -18,6 +18,15 @@ const envSchema = z.object({
|
||||
SMTP_PORT: z.string().default("1025"),
|
||||
SMTP_USER: z.string().optional(),
|
||||
SMTP_PASSWORD: z.string().optional(),
|
||||
// Managed tier flag — OAuth providers only activate when this is "true".
|
||||
// Self-hosted instances are email/password only.
|
||||
PRIMORA_MANAGED: z.string().optional(),
|
||||
// Bootstrap override for public sign-up. Without it (or an in-app setting),
|
||||
// sign-up is closed once the first user exists.
|
||||
SIGNUP_ENABLED: z.string().optional(),
|
||||
// Decrypts secret instance settings written by the backend (mail keys etc.).
|
||||
PRIMORA_ENCRYPTION_KEY: z.string().optional(),
|
||||
SMTP_SECURE: z.string().optional(),
|
||||
GITHUB_CLIENT_ID: z.string().optional(),
|
||||
GITHUB_CLIENT_SECRET: z.string().optional(),
|
||||
GOOGLE_CLIENT_ID: z.string().optional(),
|
||||
@@ -44,5 +53,8 @@ export const env = {
|
||||
JWT_TTL_SECONDS: Number(parsed.data.JWT_TTL_SECONDS),
|
||||
SMTP_PORT: Number(parsed.data.SMTP_PORT),
|
||||
AUTH_BASE_URL: parsed.data.AUTH_BASE_URL ?? parsed.data.BETTER_AUTH_URL,
|
||||
PRIMORA_MANAGED: parsed.data.PRIMORA_MANAGED === "true",
|
||||
SIGNUP_ENABLED: parsed.data.SIGNUP_ENABLED === "true",
|
||||
SMTP_SECURE: parsed.data.SMTP_SECURE === "true",
|
||||
};
|
||||
|
||||
+34
-22
@@ -2,23 +2,33 @@ import nodemailer from "nodemailer";
|
||||
import { Resend } from "resend";
|
||||
|
||||
import { env } from "./env.js";
|
||||
import { getStringSetting } from "./settings.js";
|
||||
|
||||
const resend = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null;
|
||||
|
||||
const transporter =
|
||||
!resend && env.SMTP_HOST
|
||||
? nodemailer.createTransport({
|
||||
host: env.SMTP_HOST,
|
||||
port: env.SMTP_PORT,
|
||||
secure: false,
|
||||
auth: env.SMTP_USER
|
||||
? {
|
||||
user: env.SMTP_USER,
|
||||
pass: env.SMTP_PASSWORD,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
: null;
|
||||
// Mail config resolves per send: in-app setting → env var. This keeps
|
||||
// SMTP/Resend editable from the dashboard without restarting the service.
|
||||
async function resolveMailConfig() {
|
||||
const from = (await getStringSetting("mail.from", env.MAIL_FROM))!;
|
||||
const resendApiKey = await getStringSetting("mail.resend_api_key", env.RESEND_API_KEY);
|
||||
if (resendApiKey) {
|
||||
return { from, resendApiKey } as const;
|
||||
}
|
||||
const host = await getStringSetting("mail.smtp_host", env.SMTP_HOST);
|
||||
const portRaw = await getStringSetting("mail.smtp_port", String(env.SMTP_PORT));
|
||||
const user = await getStringSetting("mail.smtp_user", env.SMTP_USER);
|
||||
const pass = await getStringSetting("mail.smtp_password", env.SMTP_PASSWORD);
|
||||
const secureRaw = await getStringSetting("mail.smtp_secure", String(env.SMTP_SECURE));
|
||||
return {
|
||||
from,
|
||||
smtp: host
|
||||
? {
|
||||
host,
|
||||
port: Number(portRaw) || 1025,
|
||||
secure: secureRaw === "true",
|
||||
auth: user ? { user, pass } : undefined,
|
||||
}
|
||||
: undefined,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export async function sendTransactionalEmail(input: {
|
||||
to: string;
|
||||
@@ -26,9 +36,12 @@ export async function sendTransactionalEmail(input: {
|
||||
text: string;
|
||||
html: string;
|
||||
}) {
|
||||
if (resend) {
|
||||
const cfg = await resolveMailConfig();
|
||||
|
||||
if ("resendApiKey" in cfg && cfg.resendApiKey) {
|
||||
const resend = new Resend(cfg.resendApiKey);
|
||||
await resend.emails.send({
|
||||
from: env.MAIL_FROM,
|
||||
from: cfg.from,
|
||||
to: [input.to],
|
||||
subject: input.subject,
|
||||
text: input.text,
|
||||
@@ -37,16 +50,15 @@ export async function sendTransactionalEmail(input: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!transporter) {
|
||||
if (!("smtp" in cfg) || !cfg.smtp) {
|
||||
throw new Error("No mail transport configured for auth service.");
|
||||
}
|
||||
|
||||
await transporter.sendMail({
|
||||
from: env.MAIL_FROM,
|
||||
await nodemailer.createTransport(cfg.smtp).sendMail({
|
||||
from: cfg.from,
|
||||
to: input.to,
|
||||
subject: input.subject,
|
||||
text: input.text,
|
||||
html: input.html,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import { authPool } from "./db.js";
|
||||
import { env } from "./env.js";
|
||||
|
||||
// Instance settings live in core.settings, written by the backend. A row
|
||||
// overrides the matching env var; no row means env (or the caller's default).
|
||||
// Secret values are AES-256-GCM ciphertext (base64, nonce || ct || tag)
|
||||
// encrypted by the backend with PRIMORA_ENCRYPTION_KEY.
|
||||
|
||||
const CACHE_TTL_MS = 15_000;
|
||||
const cache = new Map<string, { value: unknown; at: number }>();
|
||||
|
||||
export async function getSetting(key: string): Promise<unknown> {
|
||||
const hit = cache.get(key);
|
||||
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.value;
|
||||
const { rows } = await authPool.query(
|
||||
`select value, secret from core.settings where key = $1`,
|
||||
[key],
|
||||
);
|
||||
let value: unknown;
|
||||
if (rows[0]) {
|
||||
const raw = rows[0].value as unknown; // JSONB arrives parsed — a string
|
||||
value = rows[0].secret ? decryptSetting(String(raw)) : raw;
|
||||
}
|
||||
cache.set(key, { value, at: Date.now() });
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function getBoolSetting(key: string, fallback: boolean): Promise<boolean> {
|
||||
const v = await getSetting(key);
|
||||
if (v === undefined) return fallback;
|
||||
return v === true || v === "true";
|
||||
}
|
||||
|
||||
export async function getStringSetting(key: string, fallback: string | undefined): Promise<string | undefined> {
|
||||
const v = await getSetting(key);
|
||||
if (v === undefined || v === "") return fallback;
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function decryptSetting(ciphertextB64: string): string {
|
||||
const key = decodeEncryptionKey(env.PRIMORA_ENCRYPTION_KEY);
|
||||
const buf = Buffer.from(ciphertextB64, "base64");
|
||||
const nonce = buf.subarray(0, 12);
|
||||
const body = buf.subarray(12);
|
||||
const tag = body.subarray(body.length - 16);
|
||||
const ciphertext = body.subarray(0, body.length - 16);
|
||||
const decipher = crypto.createDecipheriv("aes-256-gcm", key, nonce);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
||||
}
|
||||
|
||||
// Same key formats as the Go Encryptor: 32 bytes as hex or base64.
|
||||
function decodeEncryptionKey(raw: string | undefined): Buffer {
|
||||
if (!raw) throw new Error("PRIMORA_ENCRYPTION_KEY is required to read secret settings");
|
||||
const trimmed = raw.trim();
|
||||
for (const decoded of [
|
||||
Buffer.from(trimmed, "hex"),
|
||||
Buffer.from(trimmed, "base64"),
|
||||
Buffer.from(trimmed, "base64url"),
|
||||
]) {
|
||||
if (decoded.length === 32) return decoded;
|
||||
}
|
||||
throw new Error("PRIMORA_ENCRYPTION_KEY must decode to 32 bytes (hex or base64)");
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
-- +goose Up
|
||||
-- Instance-level settings. In-app values override env vars; secrets are
|
||||
-- AES-256-GCM ciphertext (base64) produced by the backend's Encryptor and
|
||||
-- readable by the auth service, which shares PRIMORA_ENCRYPTION_KEY.
|
||||
CREATE TABLE core.settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value JSONB NOT NULL,
|
||||
secret BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
updated_by TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS core.settings;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- name: GetSetting :one
|
||||
SELECT key, value, secret, updated_at FROM core.settings WHERE key = $1;
|
||||
|
||||
-- name: ListSettings :many
|
||||
SELECT key, value, secret, updated_at FROM core.settings ORDER BY key;
|
||||
|
||||
-- name: UpsertSetting :one
|
||||
INSERT INTO core.settings (key, value, secret, updated_by)
|
||||
VALUES ($1, $2::jsonb, $3, $4)
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET value = EXCLUDED.value,
|
||||
secret = EXCLUDED.secret,
|
||||
updated_by = EXCLUDED.updated_by,
|
||||
updated_at = NOW()
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteSetting :exec
|
||||
DELETE FROM core.settings WHERE key = $1;
|
||||
@@ -92,6 +92,7 @@ func Bootstrap(ctx context.Context) (*App, error) {
|
||||
return nil, fmt.Errorf("encryption key: %w", err)
|
||||
}
|
||||
platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, encryptor, logger)
|
||||
settings := services.NewSettingsService(repo, encryptor)
|
||||
|
||||
if cfg.Env == "production" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
@@ -126,10 +127,12 @@ func Bootstrap(ctx context.Context) (*App, error) {
|
||||
APIKeyPerMinute: cfg.APIKeyRateLimitPerMin,
|
||||
UserPerMinute: cfg.UserRateLimitPerMin,
|
||||
},
|
||||
RateLimitResolver: settings.RateLimitPerMinute,
|
||||
}.ResolveActor())
|
||||
|
||||
handler := &handlers.HTTPHandler{
|
||||
Platform: platform,
|
||||
Settings: settings,
|
||||
Validate: validator.New(),
|
||||
Metrics: metrics,
|
||||
Readiness: func(c *gin.Context) map[string]any {
|
||||
|
||||
@@ -336,6 +336,14 @@ type CoreScheduledJobRun struct {
|
||||
FinishedAt pgtype.Timestamptz `json:"finished_at"`
|
||||
}
|
||||
|
||||
type CoreSetting struct {
|
||||
Key string `json:"key"`
|
||||
Value []byte `json:"value"`
|
||||
Secret bool `json:"secret"`
|
||||
UpdatedBy *string `json:"updated_by"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CoreUser struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
AuthSubject string `json:"auth_subject"`
|
||||
|
||||
@@ -46,6 +46,7 @@ type Querier interface {
|
||||
DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
|
||||
DeleteProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
|
||||
DeleteScheduledJob(ctx context.Context, arg DeleteScheduledJobParams) (uuid.UUID, error)
|
||||
DeleteSetting(ctx context.Context, key string) error
|
||||
DeleteWebhook(ctx context.Context, arg DeleteWebhookParams) (CoreWebhook, error)
|
||||
EventTotals(ctx context.Context, arg EventTotalsParams) (EventTotalsRow, error)
|
||||
EventTypeSeries(ctx context.Context, arg EventTypeSeriesParams) ([]EventTypeSeriesRow, error)
|
||||
@@ -68,6 +69,7 @@ type Querier interface {
|
||||
GetProjectMembership(ctx context.Context, arg GetProjectMembershipParams) (GetProjectMembershipRow, error)
|
||||
GetProjectOverview(ctx context.Context, id uuid.UUID) (GetProjectOverviewRow, error)
|
||||
GetScheduledJobByID(ctx context.Context, id uuid.UUID) (CoreScheduledJob, error)
|
||||
GetSetting(ctx context.Context, key string) (GetSettingRow, error)
|
||||
GetUserByAuthSubject(ctx context.Context, authSubject string) (CoreUser, error)
|
||||
GetUserByID(ctx context.Context, id uuid.UUID) (CoreUser, error)
|
||||
GetWebhookByID(ctx context.Context, id uuid.UUID) (CoreWebhook, error)
|
||||
@@ -97,6 +99,7 @@ type Querier interface {
|
||||
ListProjectsForOrganization(ctx context.Context, arg ListProjectsForOrganizationParams) ([]ListProjectsForOrganizationRow, error)
|
||||
ListScheduledJobRuns(ctx context.Context, arg ListScheduledJobRunsParams) ([]CoreScheduledJobRun, error)
|
||||
ListScheduledJobs(ctx context.Context, projectID uuid.UUID) ([]CoreScheduledJob, error)
|
||||
ListSettings(ctx context.Context) ([]ListSettingsRow, error)
|
||||
ListWebhookDeliveries(ctx context.Context, arg ListWebhookDeliveriesParams) ([]CoreWebhookDelivery, error)
|
||||
ListWebhooks(ctx context.Context, projectID uuid.UUID) ([]CoreWebhook, error)
|
||||
ListWebhooksForEvent(ctx context.Context, arg ListWebhooksForEventParams) ([]CoreWebhook, error)
|
||||
@@ -132,6 +135,7 @@ type Querier interface {
|
||||
UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJobParams) (CoreScheduledJob, error)
|
||||
UpdateWebhook(ctx context.Context, arg UpdateWebhookParams) (CoreWebhook, error)
|
||||
UpsertComponent(ctx context.Context, arg UpsertComponentParams) (CoreComponent, error)
|
||||
UpsertSetting(ctx context.Context, arg UpsertSettingParams) (CoreSetting, error)
|
||||
UpsertUser(ctx context.Context, arg UpsertUserParams) (CoreUser, error)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: settings.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const deleteSetting = `-- name: DeleteSetting :exec
|
||||
DELETE FROM core.settings WHERE key = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteSetting(ctx context.Context, key string) error {
|
||||
_, err := q.db.Exec(ctx, deleteSetting, key)
|
||||
return err
|
||||
}
|
||||
|
||||
const getSetting = `-- name: GetSetting :one
|
||||
SELECT key, value, secret, updated_at FROM core.settings WHERE key = $1
|
||||
`
|
||||
|
||||
type GetSettingRow struct {
|
||||
Key string `json:"key"`
|
||||
Value []byte `json:"value"`
|
||||
Secret bool `json:"secret"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetSetting(ctx context.Context, key string) (GetSettingRow, error) {
|
||||
row := q.db.QueryRow(ctx, getSetting, key)
|
||||
var i GetSettingRow
|
||||
err := row.Scan(
|
||||
&i.Key,
|
||||
&i.Value,
|
||||
&i.Secret,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listSettings = `-- name: ListSettings :many
|
||||
SELECT key, value, secret, updated_at FROM core.settings ORDER BY key
|
||||
`
|
||||
|
||||
type ListSettingsRow struct {
|
||||
Key string `json:"key"`
|
||||
Value []byte `json:"value"`
|
||||
Secret bool `json:"secret"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListSettings(ctx context.Context) ([]ListSettingsRow, error) {
|
||||
rows, err := q.db.Query(ctx, listSettings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListSettingsRow
|
||||
for rows.Next() {
|
||||
var i ListSettingsRow
|
||||
if err := rows.Scan(
|
||||
&i.Key,
|
||||
&i.Value,
|
||||
&i.Secret,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const upsertSetting = `-- name: UpsertSetting :one
|
||||
INSERT INTO core.settings (key, value, secret, updated_by)
|
||||
VALUES ($1, $2::jsonb, $3, $4)
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET value = EXCLUDED.value,
|
||||
secret = EXCLUDED.secret,
|
||||
updated_by = EXCLUDED.updated_by,
|
||||
updated_at = NOW()
|
||||
RETURNING key, value, secret, updated_by, updated_at
|
||||
`
|
||||
|
||||
type UpsertSettingParams struct {
|
||||
Key string `json:"key"`
|
||||
Column2 []byte `json:"column_2"`
|
||||
Secret bool `json:"secret"`
|
||||
UpdatedBy *string `json:"updated_by"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertSetting(ctx context.Context, arg UpsertSettingParams) (CoreSetting, error) {
|
||||
row := q.db.QueryRow(ctx, upsertSetting,
|
||||
arg.Key,
|
||||
arg.Column2,
|
||||
arg.Secret,
|
||||
arg.UpdatedBy,
|
||||
)
|
||||
var i CoreSetting
|
||||
err := row.Scan(
|
||||
&i.Key,
|
||||
&i.Value,
|
||||
&i.Secret,
|
||||
&i.UpdatedBy,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
|
||||
type HTTPHandler struct {
|
||||
Platform *services.PlatformService
|
||||
Settings *services.SettingsService
|
||||
Validate *validator.Validate
|
||||
Metrics *observability.Metrics
|
||||
Readiness func(*gin.Context) map[string]any
|
||||
@@ -33,6 +34,10 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
|
||||
|
||||
api := router.Group("/api/v1")
|
||||
api.GET("/me", h.me)
|
||||
api.GET("/instance/public", h.instancePublic)
|
||||
api.GET("/instance/settings", h.listInstanceSettings)
|
||||
api.PUT("/instance/settings/:key", h.updateInstanceSetting)
|
||||
api.DELETE("/instance/settings/:key", h.deleteInstanceSetting)
|
||||
api.POST("/bootstrap", h.bootstrap)
|
||||
api.GET("/organizations", h.organizations)
|
||||
api.POST("/organizations", h.createOrganization)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/tdvorak/primora/apps/backend/internal/middleware"
|
||||
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
|
||||
"github.com/tdvorak/primora/apps/backend/internal/services"
|
||||
)
|
||||
|
||||
// instancePublic serves unauthenticated bootstrap state to the login page.
|
||||
// It intentionally exposes only what the sign-in UI needs.
|
||||
func (h *HTTPHandler) instancePublic(c *gin.Context) {
|
||||
users, err := h.Settings.AuthUserCount(c.Request.Context())
|
||||
if err != nil {
|
||||
apperrors.Abort(c, http.StatusInternalServerError, "settings_unavailable", err.Error())
|
||||
return
|
||||
}
|
||||
signupEnabled := false
|
||||
if users > 0 {
|
||||
signupEnabled, err = h.Settings.GetBool(c.Request.Context(), "auth.signup_enabled")
|
||||
if err != nil {
|
||||
apperrors.Abort(c, http.StatusInternalServerError, "settings_unavailable", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"signup_enabled": signupEnabled,
|
||||
"bootstrap_required": users == 0,
|
||||
"social_providers": managedSocialProviders(),
|
||||
})
|
||||
}
|
||||
|
||||
// managedSocialProviders mirrors the auth service's managed gate: OAuth only
|
||||
// exists when PRIMORA_MANAGED=true and the provider's client id is set.
|
||||
func managedSocialProviders() []string {
|
||||
if os.Getenv("PRIMORA_MANAGED") != "true" {
|
||||
return []string{}
|
||||
}
|
||||
providers := []string{}
|
||||
for _, p := range []struct{ name, envKey string }{
|
||||
{"github", "GITHUB_CLIENT_ID"},
|
||||
{"google", "GOOGLE_CLIENT_ID"},
|
||||
{"discord", "DISCORD_CLIENT_ID"},
|
||||
{"microsoft", "MICROSOFT_CLIENT_ID"},
|
||||
} {
|
||||
if os.Getenv(p.envKey) != "" {
|
||||
providers = append(providers, p.name)
|
||||
}
|
||||
}
|
||||
return providers
|
||||
}
|
||||
|
||||
// requireInstanceAdmin gates handlers on the platform admin role (better-auth
|
||||
// `user.role`), which is distinct from org/project roles.
|
||||
func (h *HTTPHandler) requireInstanceAdmin(c *gin.Context) bool {
|
||||
actor, ok := middleware.RequireActor(c)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if !actor.IsUser() || !h.Settings.IsAdmin(c.Request.Context(), actor.AuthSubject) {
|
||||
apperrors.Abort(c, http.StatusForbidden, "admin_required", "instance admin role required")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) listInstanceSettings(c *gin.Context) {
|
||||
if !h.requireInstanceAdmin(c) {
|
||||
return
|
||||
}
|
||||
settings, err := h.Settings.ListResolved(c.Request.Context())
|
||||
if err != nil {
|
||||
apperrors.Abort(c, http.StatusInternalServerError, "settings_unavailable", err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"settings": settings})
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) updateInstanceSetting(c *gin.Context) {
|
||||
if !h.requireInstanceAdmin(c) {
|
||||
return
|
||||
}
|
||||
key := c.Param("key")
|
||||
if _, ok := services.SettingSpecs[key]; !ok {
|
||||
apperrors.Abort(c, http.StatusNotFound, "unknown_setting", "unknown setting key")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Value json.RawMessage `json:"value" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || len(body.Value) == 0 {
|
||||
apperrors.Abort(c, http.StatusBadRequest, "invalid_body", "expected {\"value\": ...}")
|
||||
return
|
||||
}
|
||||
actor, _ := middleware.ActorFromContext(c)
|
||||
if err := h.Settings.Store(c.Request.Context(), key, body.Value, actor.Email); err != nil {
|
||||
apperrors.Abort(c, http.StatusBadRequest, "invalid_value", err.Error())
|
||||
return
|
||||
}
|
||||
value, source, err := h.Settings.Resolve(c.Request.Context(), key)
|
||||
if err != nil {
|
||||
apperrors.Abort(c, http.StatusInternalServerError, "settings_unavailable", err.Error())
|
||||
return
|
||||
}
|
||||
spec := services.SettingSpecs[key]
|
||||
resp := gin.H{"key": key, "type": spec.Type, "secret": spec.Secret, "is_set": true, "source": source}
|
||||
if !spec.Secret {
|
||||
resp["value"] = value
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) deleteInstanceSetting(c *gin.Context) {
|
||||
if !h.requireInstanceAdmin(c) {
|
||||
return
|
||||
}
|
||||
key := c.Param("key")
|
||||
if _, ok := services.SettingSpecs[key]; !ok {
|
||||
apperrors.Abort(c, http.StatusNotFound, "unknown_setting", "unknown setting key")
|
||||
return
|
||||
}
|
||||
if err := h.Settings.Delete(c.Request.Context(), key); err != nil {
|
||||
apperrors.Abort(c, http.StatusInternalServerError, "settings_unavailable", err.Error())
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -37,6 +37,9 @@ type AuthMiddleware struct {
|
||||
Redis *redis.Client
|
||||
Verifier *auth.Verifier
|
||||
RateLimits RateLimitConfig
|
||||
// RateLimitResolver overrides RateLimits with live instance settings when
|
||||
// set — it resolves "user" and "api_key" limits per request (cached).
|
||||
RateLimitResolver func(ctx context.Context, scope string) int
|
||||
}
|
||||
|
||||
type RateLimitConfig struct {
|
||||
@@ -44,6 +47,16 @@ type RateLimitConfig struct {
|
||||
UserPerMinute int
|
||||
}
|
||||
|
||||
func (m AuthMiddleware) rateLimit(ctx context.Context, scope string, fallback int) int {
|
||||
if m.RateLimitResolver == nil {
|
||||
return fallback
|
||||
}
|
||||
if v := m.RateLimitResolver(ctx, scope); v >= 0 {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func RequestID() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
requestID := c.Request.Header.Get("X-Request-ID")
|
||||
@@ -105,7 +118,7 @@ func (m AuthMiddleware) ResolveActor() gin.HandlerFunc {
|
||||
if actor.UserID != nil {
|
||||
userIdentity = actor.UserID.String()
|
||||
}
|
||||
if !m.enforceRateLimit(c, "user", userIdentity, m.RateLimits.UserPerMinute, "User rate limit exceeded") {
|
||||
if !m.enforceRateLimit(c, "user", userIdentity, m.rateLimit(c.Request.Context(), "user", m.RateLimits.UserPerMinute), "User rate limit exceeded") {
|
||||
return
|
||||
}
|
||||
c.Set(actorKey, actor)
|
||||
@@ -123,7 +136,7 @@ func (m AuthMiddleware) ResolveActor() gin.HandlerFunc {
|
||||
apperrors.Abort(c, http.StatusUnauthorized, "invalid_api_key", err.Error())
|
||||
return
|
||||
}
|
||||
if !m.enforceRateLimit(c, "apikey", actor.APIKeyPrefix, m.RateLimits.APIKeyPerMinute, "API key rate limit exceeded") {
|
||||
if !m.enforceRateLimit(c, "apikey", actor.APIKeyPrefix, m.rateLimit(c.Request.Context(), "api_key", m.RateLimits.APIKeyPerMinute), "API key rate limit exceeded") {
|
||||
return
|
||||
}
|
||||
if need := requiredAPIKeyScope(c.Request.Method, c.Request.URL.Path); !actor.HasScope(need) {
|
||||
|
||||
@@ -56,3 +56,35 @@ func (r *CoreRepository) GetAPIKeyByPrefix(ctx context.Context, prefix string) (
|
||||
func (r *CoreRepository) TouchAPIKey(ctx context.Context, id uuid.UUID) error {
|
||||
return r.queries.TouchAPIKey(ctx, id)
|
||||
}
|
||||
|
||||
// Auth-user lookups hit the better-auth tables in the public schema. They are
|
||||
// raw queries on purpose: sqlc only knows the core schema, and a view would
|
||||
// break when the backend migrates before the auth service has created its
|
||||
// tables.
|
||||
func (r *CoreRepository) GetAuthUserRole(ctx context.Context, authSubject string) (string, error) {
|
||||
var role string
|
||||
err := r.pool.QueryRow(ctx, `select coalesce(role, 'user') from public."user" where id = $1`, authSubject).Scan(&role)
|
||||
return role, err
|
||||
}
|
||||
|
||||
func (r *CoreRepository) CountAuthUsers(ctx context.Context) (int64, error) {
|
||||
var n int64
|
||||
err := r.pool.QueryRow(ctx, `select count(*) from public."user"`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (r *CoreRepository) GetSetting(ctx context.Context, key string) (db.GetSettingRow, error) {
|
||||
return r.queries.GetSetting(ctx, key)
|
||||
}
|
||||
|
||||
func (r *CoreRepository) ListSettings(ctx context.Context) ([]db.ListSettingsRow, error) {
|
||||
return r.queries.ListSettings(ctx)
|
||||
}
|
||||
|
||||
func (r *CoreRepository) UpsertSetting(ctx context.Context, params db.UpsertSettingParams) (db.CoreSetting, error) {
|
||||
return r.queries.UpsertSetting(ctx, params)
|
||||
}
|
||||
|
||||
func (r *CoreRepository) DeleteSetting(ctx context.Context, key string) error {
|
||||
return r.queries.DeleteSetting(ctx, key)
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||
"github.com/tdvorak/primora/apps/backend/internal/repositories"
|
||||
"github.com/tdvorak/primora/apps/backend/internal/secrets"
|
||||
)
|
||||
|
||||
// SettingSpec describes a settable instance key: value type, the env var used
|
||||
// as fallback when no DB row exists, and a built-in default.
|
||||
type SettingSpec struct {
|
||||
Key string
|
||||
Type string // "bool" | "int" | "string"
|
||||
Secret bool
|
||||
EnvKey string
|
||||
Default string
|
||||
}
|
||||
|
||||
// SettingSpecs is the registry of keys manageable in-app. Env vars stay as
|
||||
// defaults — an app value wins when present.
|
||||
var SettingSpecs = map[string]SettingSpec{
|
||||
"auth.signup_enabled": {Key: "auth.signup_enabled", Type: "bool", EnvKey: "SIGNUP_ENABLED", Default: "false"},
|
||||
"mail.from": {Key: "mail.from", Type: "string", EnvKey: "MAIL_FROM", Default: "Primora <[email protected]>"},
|
||||
"mail.resend_api_key": {Key: "mail.resend_api_key", Type: "string", Secret: true, EnvKey: "RESEND_API_KEY"},
|
||||
"mail.smtp_host": {Key: "mail.smtp_host", Type: "string", EnvKey: "SMTP_HOST"},
|
||||
"mail.smtp_port": {Key: "mail.smtp_port", Type: "int", EnvKey: "SMTP_PORT", Default: "1025"},
|
||||
"mail.smtp_user": {Key: "mail.smtp_user", Type: "string", EnvKey: "SMTP_USER"},
|
||||
"mail.smtp_password": {Key: "mail.smtp_password", Type: "string", Secret: true, EnvKey: "SMTP_PASSWORD"},
|
||||
"mail.smtp_secure": {Key: "mail.smtp_secure", Type: "bool", EnvKey: "SMTP_SECURE", Default: "false"},
|
||||
"ratelimit.user_per_minute": {Key: "ratelimit.user_per_minute", Type: "int", EnvKey: "USER_RATE_LIMIT_PER_MINUTE", Default: "240"},
|
||||
"ratelimit.api_key_per_minute": {Key: "ratelimit.api_key_per_minute", Type: "int", EnvKey: "API_KEY_RATE_LIMIT_PER_MINUTE", Default: "600"},
|
||||
}
|
||||
|
||||
const settingsCacheTTL = 15 * time.Second
|
||||
|
||||
// resolvedSetting holds the stored string form: plaintext for normal keys,
|
||||
// base64 ciphertext for secrets — Resolve() decrypts on read.
|
||||
type resolvedSetting struct {
|
||||
value string
|
||||
secret bool
|
||||
}
|
||||
|
||||
// SettingsService resolves instance settings: DB row > env var > default.
|
||||
// Values cache briefly so per-request consumers (rate limits) stay cheap.
|
||||
type SettingsService struct {
|
||||
repo *repositories.CoreRepository
|
||||
encryptor *secrets.Encryptor
|
||||
|
||||
mu sync.Mutex
|
||||
cache map[string]resolvedSetting
|
||||
cacheAt time.Time
|
||||
}
|
||||
|
||||
func NewSettingsService(repo *repositories.CoreRepository, encryptor *secrets.Encryptor) *SettingsService {
|
||||
return &SettingsService{repo: repo, encryptor: encryptor}
|
||||
}
|
||||
|
||||
// Resolve returns the effective string value plus its source
|
||||
// ("app" | "env" | "default"). Secret DB values are decrypted.
|
||||
func (s *SettingsService) Resolve(ctx context.Context, key string) (value, source string, err error) {
|
||||
spec, ok := SettingSpecs[key]
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("unknown setting: %s", key)
|
||||
}
|
||||
row, err := s.stored(ctx, key)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if row != nil {
|
||||
raw := row.value
|
||||
if row.secret {
|
||||
raw, err = s.decrypt(raw)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("decrypt %s: %w", key, err)
|
||||
}
|
||||
}
|
||||
return raw, "app", nil
|
||||
}
|
||||
if env := os.Getenv(spec.EnvKey); env != "" {
|
||||
return env, "env", nil
|
||||
}
|
||||
return spec.Default, "default", nil
|
||||
}
|
||||
|
||||
func (s *SettingsService) GetBool(ctx context.Context, key string) (bool, error) {
|
||||
raw, _, err := s.Resolve(ctx, key)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
v, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("setting %s is not a bool: %q", key, raw)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (s *SettingsService) GetInt(ctx context.Context, key string) (int, error) {
|
||||
raw, _, err := s.Resolve(ctx, key)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("setting %s is not an int: %q", key, raw)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// RateLimitPerMinute is the hot-path helper for the auth middleware.
|
||||
func (s *SettingsService) RateLimitPerMinute(ctx context.Context, scope string) int {
|
||||
key := "ratelimit.user_per_minute"
|
||||
if scope == "api_key" {
|
||||
key = "ratelimit.api_key_per_minute"
|
||||
}
|
||||
v, err := s.GetInt(ctx, key)
|
||||
if err != nil {
|
||||
spec := SettingSpecs[key]
|
||||
fallback, _ := strconv.Atoi(spec.Default)
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// ValidateAndStore type-checks the raw JSON value against the registry and
|
||||
// persists it. Secret values are encrypted before they touch the DB.
|
||||
func (s *SettingsService) Store(ctx context.Context, key string, raw json.RawMessage, updatedBy string) error {
|
||||
spec, ok := SettingSpecs[key]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown setting: %s", key)
|
||||
}
|
||||
plain, err := normalizeSetting(spec, raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
value := []byte(strconv.Quote(plain))
|
||||
if spec.Secret {
|
||||
ciphertext, err := s.encryptor.Encrypt([]byte(plain))
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt %s: %w", key, err)
|
||||
}
|
||||
value = []byte(strconv.Quote(base64.StdEncoding.EncodeToString(ciphertext)))
|
||||
}
|
||||
_, err = s.repo.UpsertSetting(ctx, db.UpsertSettingParams{
|
||||
Key: key,
|
||||
Column2: value,
|
||||
Secret: spec.Secret,
|
||||
UpdatedBy: &updatedBy,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.invalidate()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SettingsService) Delete(ctx context.Context, key string) error {
|
||||
if _, ok := SettingSpecs[key]; !ok {
|
||||
return fmt.Errorf("unknown setting: %s", key)
|
||||
}
|
||||
if err := s.repo.DeleteSetting(ctx, key); err != nil {
|
||||
return err
|
||||
}
|
||||
s.invalidate()
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsAdmin reports whether the actor holds the platform admin role
|
||||
// (better-auth `user.role`), not an org/project role.
|
||||
func (s *SettingsService) IsAdmin(ctx context.Context, authSubject string) bool {
|
||||
role, err := s.repo.GetAuthUserRole(ctx, authSubject)
|
||||
return err == nil && role == "admin"
|
||||
}
|
||||
|
||||
func (s *SettingsService) AuthUserCount(ctx context.Context) (int64, error) {
|
||||
return s.repo.CountAuthUsers(ctx)
|
||||
}
|
||||
|
||||
// ListResolved returns every registered key with its effective state.
|
||||
// Secret values are masked — never returned.
|
||||
func (s *SettingsService) ListResolved(ctx context.Context) ([]ResolvedSetting, error) {
|
||||
stored, err := s.loadStored(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ResolvedSetting, 0, len(SettingSpecs))
|
||||
for _, key := range sortedSettingKeys() {
|
||||
spec := SettingSpecs[key]
|
||||
entry := ResolvedSetting{Key: key, Secret: spec.Secret, Type: spec.Type}
|
||||
if row, ok := stored[key]; ok {
|
||||
entry.IsSet = true
|
||||
entry.Source = "app"
|
||||
if !spec.Secret {
|
||||
entry.Value = json.RawMessage(strconv.Quote(row.value))
|
||||
}
|
||||
} else if env := os.Getenv(spec.EnvKey); env != "" {
|
||||
entry.Source = "env"
|
||||
if !spec.Secret {
|
||||
entry.Value = json.RawMessage(strconv.Quote(env))
|
||||
}
|
||||
} else {
|
||||
entry.Source = "default"
|
||||
if !spec.Secret {
|
||||
entry.Value = json.RawMessage(strconv.Quote(spec.Default))
|
||||
}
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type ResolvedSetting struct {
|
||||
Key string `json:"key"`
|
||||
Type string `json:"type"`
|
||||
Secret bool `json:"secret"`
|
||||
IsSet bool `json:"is_set"`
|
||||
Source string `json:"source"`
|
||||
Value json.RawMessage `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
func sortedSettingKeys() []string {
|
||||
keys := make([]string, 0, len(SettingSpecs))
|
||||
for k := range SettingSpecs {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func (s *SettingsService) loadStored(ctx context.Context) (map[string]resolvedSetting, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.cache != nil && time.Since(s.cacheAt) < settingsCacheTTL {
|
||||
return s.cache, nil
|
||||
}
|
||||
rows, err := s.repo.ListSettings(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fresh := make(map[string]resolvedSetting, len(rows))
|
||||
for _, r := range rows {
|
||||
encoded, err := unwrapSettingValue(r.Value)
|
||||
if err != nil {
|
||||
continue // unreadable row — treat as unset rather than break consumers
|
||||
}
|
||||
fresh[r.Key] = resolvedSetting{value: encoded, secret: r.Secret}
|
||||
}
|
||||
s.cache = fresh
|
||||
s.cacheAt = time.Now()
|
||||
return fresh, nil
|
||||
}
|
||||
|
||||
func (s *SettingsService) stored(ctx context.Context, key string) (*resolvedSetting, error) {
|
||||
all, err := s.loadStored(ctx)
|
||||
if err != nil {
|
||||
// cold-cache fallback: direct lookup
|
||||
row, err := s.repo.GetSetting(ctx, key)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encoded, err := unwrapSettingValue(row.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resolvedSetting{value: encoded, secret: row.Secret}, nil
|
||||
}
|
||||
if row, ok := all[key]; ok {
|
||||
return &row, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *SettingsService) decrypt(ciphertextB64 string) (string, error) {
|
||||
raw, err := base64.StdEncoding.DecodeString(ciphertextB64)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
plain, err := s.encryptor.Decrypt(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
func (s *SettingsService) invalidate() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.cache = nil
|
||||
}
|
||||
|
||||
// unwrapSettingValue decodes the JSONB column: every stored value is a JSON
|
||||
// string — the plaintext for normal keys, base64 ciphertext for secrets.
|
||||
func unwrapSettingValue(raw []byte) (string, error) {
|
||||
var encoded string
|
||||
if err := json.Unmarshal(raw, &encoded); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
// normalizeSetting validates raw JSON against the spec type and returns the
|
||||
// canonical string form stored/compared internally.
|
||||
func normalizeSetting(spec SettingSpec, raw json.RawMessage) (string, error) {
|
||||
switch spec.Type {
|
||||
case "bool":
|
||||
var v bool
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
return "", fmt.Errorf("%s expects a boolean", spec.Key)
|
||||
}
|
||||
return strconv.FormatBool(v), nil
|
||||
case "int":
|
||||
var v int
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
return "", fmt.Errorf("%s expects an integer", spec.Key)
|
||||
}
|
||||
if v < 0 {
|
||||
return "", fmt.Errorf("%s must be >= 0", spec.Key)
|
||||
}
|
||||
return strconv.Itoa(v), nil
|
||||
default:
|
||||
var v string
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
return "", fmt.Errorf("%s expects a string", spec.Key)
|
||||
}
|
||||
if len(v) > 512 {
|
||||
return "", fmt.Errorf("%s exceeds 512 chars", spec.Key)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeSetting(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
boolSpec := SettingSpecs["auth.signup_enabled"]
|
||||
intSpec := SettingSpecs["ratelimit.user_per_minute"]
|
||||
strSpec := SettingSpecs["mail.smtp_host"]
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
spec SettingSpec
|
||||
raw string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "bool true", spec: boolSpec, raw: `true`, want: "true"},
|
||||
{name: "bool false", spec: boolSpec, raw: `false`, want: "false"},
|
||||
{name: "bool rejects string", spec: boolSpec, raw: `"true"`, wantErr: true},
|
||||
{name: "bool rejects int", spec: boolSpec, raw: `1`, wantErr: true},
|
||||
{name: "int accepts", spec: intSpec, raw: `120`, want: "120"},
|
||||
{name: "int zero allowed", spec: intSpec, raw: `0`, want: "0"},
|
||||
{name: "int rejects negative", spec: intSpec, raw: `-5`, wantErr: true},
|
||||
{name: "int rejects float", spec: intSpec, raw: `1.5`, wantErr: true},
|
||||
{name: "int rejects string", spec: intSpec, raw: `"120"`, wantErr: true},
|
||||
{name: "string accepts", spec: strSpec, raw: `"smtp.example.com"`, want: "smtp.example.com"},
|
||||
{name: "string rejects number", spec: strSpec, raw: `42`, wantErr: true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := normalizeSetting(tc.spec, json.RawMessage(tc.raw))
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("got %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortedSettingKeysDeterministic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
first := sortedSettingKeys()
|
||||
second := sortedSettingKeys()
|
||||
if len(first) != len(SettingSpecs) {
|
||||
t.Fatalf("expected %d keys, got %d", len(SettingSpecs), len(first))
|
||||
}
|
||||
for i := range first {
|
||||
if first[i] != second[i] {
|
||||
t.Fatalf("key order not deterministic at %d: %q vs %q", i, first[i], second[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ tags:
|
||||
- name: Integrations
|
||||
- name: Webhooks
|
||||
- name: Automation
|
||||
- name: Instance
|
||||
paths:
|
||||
/health/liveness:
|
||||
get:
|
||||
@@ -67,6 +68,87 @@ paths:
|
||||
$ref: "#/components/schemas/MeResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/instance/public:
|
||||
get:
|
||||
tags: [Instance]
|
||||
operationId: getInstancePublic
|
||||
summary: Public instance state for the login page — no auth required
|
||||
responses:
|
||||
"200":
|
||||
description: Public instance configuration
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InstancePublicConfig"
|
||||
/instance/settings:
|
||||
get:
|
||||
tags: [Instance]
|
||||
operationId: listInstanceSettings
|
||||
summary: List instance settings (platform admin only)
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: Resolved instance settings
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InstanceSettingsResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"403":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/instance/settings/{key}:
|
||||
put:
|
||||
tags: [Instance]
|
||||
operationId: updateInstanceSetting
|
||||
summary: Set an instance setting override (platform admin only)
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: key
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UpdateInstanceSettingRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Setting stored
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InstanceSetting"
|
||||
"400":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"403":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"404":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
delete:
|
||||
tags: [Instance]
|
||||
operationId: deleteInstanceSetting
|
||||
summary: Revert a setting to its env/default value (platform admin only)
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: key
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
description: Setting reverted
|
||||
"403":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"404":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/bootstrap:
|
||||
post:
|
||||
tags: [Platform]
|
||||
@@ -1952,6 +2034,54 @@ components:
|
||||
type: string
|
||||
required: [code, message]
|
||||
required: [status, error]
|
||||
InstancePublicConfig:
|
||||
type: object
|
||||
properties:
|
||||
signup_enabled:
|
||||
type: boolean
|
||||
description: Whether public sign-up is open (bootstrap always allowed)
|
||||
bootstrap_required:
|
||||
type: boolean
|
||||
description: True when no user exists yet — first sign-up becomes instance admin
|
||||
social_providers:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Enabled OAuth providers — empty on self-hosted
|
||||
required: [signup_enabled, bootstrap_required, social_providers]
|
||||
InstanceSetting:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
enum: [bool, int, string]
|
||||
secret:
|
||||
type: boolean
|
||||
is_set:
|
||||
type: boolean
|
||||
description: True when an in-app override exists
|
||||
source:
|
||||
type: string
|
||||
enum: [app, env, default]
|
||||
value:
|
||||
description: Effective value — never returned for secret keys
|
||||
required: [key, type, secret, is_set, source]
|
||||
InstanceSettingsResponse:
|
||||
type: object
|
||||
properties:
|
||||
settings:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/InstanceSetting"
|
||||
required: [settings]
|
||||
UpdateInstanceSettingRequest:
|
||||
type: object
|
||||
properties:
|
||||
value:
|
||||
description: JSON value matching the key's declared type
|
||||
required: [value]
|
||||
BootstrapRequest:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
CreateInvitationRequest,
|
||||
CreateOrganizationRequest,
|
||||
HealthService,
|
||||
InstanceService,
|
||||
OrganizationsService,
|
||||
PlatformService,
|
||||
ProjectsService,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
CopyBucketObjectRequest,
|
||||
type Bucket,
|
||||
type BucketObject,
|
||||
type InstanceSetting,
|
||||
type OrganizationSummary,
|
||||
type ProjectSummary,
|
||||
} from "@primora/api-client";
|
||||
@@ -141,6 +143,7 @@ export default function App() {
|
||||
const [memberMessage, setMemberMessage] = createSignal("");
|
||||
const [apiKeySecret, setApiKeySecret] = createSignal("");
|
||||
const [collectionsMessage, setCollectionsMessage] = createSignal("");
|
||||
const [instanceMessage, setInstanceMessage] = createSignal("");
|
||||
|
||||
const [selectedOrganizationID, setSelectedOrganizationID] = createSignal<string | undefined>();
|
||||
const [selectedProjectID, setSelectedProjectID] = createSignal<string | undefined>();
|
||||
@@ -237,6 +240,47 @@ export default function App() {
|
||||
}
|
||||
});
|
||||
|
||||
// Public instance state — fetched before login so the auth page can hide
|
||||
// sign-up/social options the instance does not offer.
|
||||
const [instancePublic] = createResource(async () => {
|
||||
if (isDemo) return { signup_enabled: true, bootstrap_required: false, social_providers: [] };
|
||||
try {
|
||||
return await InstanceService.getInstancePublic();
|
||||
} catch {
|
||||
// Endpoint unreachable (backend down or pre-migration): keep sign-in
|
||||
// visible rather than lock the page out.
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const canSignUp = createMemo(() => {
|
||||
const cfg = instancePublic();
|
||||
if (!cfg) return true; // unknown — let the backend enforce
|
||||
return cfg.bootstrap_required || cfg.signup_enabled;
|
||||
});
|
||||
const enabledSocialProviders = createMemo(() => instancePublic()?.social_providers ?? []);
|
||||
|
||||
const isPlatformAdmin = createMemo(() => {
|
||||
const role = (session()?.data?.user as { role?: string } | undefined)?.role;
|
||||
return isDemo || role === "admin";
|
||||
});
|
||||
|
||||
const [instanceSettings, { refetch: refetchInstanceSettings }] = createResource(
|
||||
() => (activeView() === "settings" && isPlatformAdmin() && session()?.data ? true : null),
|
||||
async (): Promise<InstanceSetting[]> => {
|
||||
if (isDemo) return demoService.listInstanceSettings();
|
||||
return (await InstanceService.listInstanceSettings()).settings;
|
||||
},
|
||||
);
|
||||
|
||||
// Clamp the auth mode to what the instance permits.
|
||||
createEffect(() => {
|
||||
const cfg = instancePublic();
|
||||
if (!cfg || isDemo) return;
|
||||
if (cfg.bootstrap_required) setMode("sign-up");
|
||||
else if (!cfg.signup_enabled) setMode("sign-in");
|
||||
});
|
||||
|
||||
const [platform, { refetch: refetchPlatform }] = createResource(
|
||||
() => session()?.data?.user.id ?? null,
|
||||
async () => {
|
||||
@@ -312,6 +356,7 @@ export default function App() {
|
||||
const storagePending = createMemo(() => isPendingPrefix("storage-"));
|
||||
const collectionPending = createMemo(() => isPendingPrefix("collection-"));
|
||||
const documentPending = createMemo(() => isPendingPrefix("document-"));
|
||||
const instancePending = createMemo(() => isPendingPrefix("instance-"));
|
||||
|
||||
const [apiKeys, { refetch: refetchAPIKeys }] = createResource(
|
||||
() => activeProject()?.id ?? null,
|
||||
@@ -1431,6 +1476,32 @@ export default function App() {
|
||||
});
|
||||
}
|
||||
|
||||
async function saveInstanceSetting(key: string, value: unknown) {
|
||||
await runPending("instance-", async () => {
|
||||
setInstanceMessage("");
|
||||
try {
|
||||
await InstanceService.updateInstanceSetting({ key, requestBody: { value } });
|
||||
await refetchInstanceSettings();
|
||||
setInstanceMessage("Setting saved.");
|
||||
} catch (error) {
|
||||
setInstanceMessage(getErrorMessage(error, "Failed to save setting"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function resetInstanceSetting(key: string) {
|
||||
await runPending("instance-", async () => {
|
||||
setInstanceMessage("");
|
||||
try {
|
||||
await InstanceService.deleteInstanceSetting({ key });
|
||||
await refetchInstanceSettings();
|
||||
setInstanceMessage(`"${key}" reverted to its env/default value.`);
|
||||
} catch (error) {
|
||||
setInstanceMessage(getErrorMessage(error, "Failed to reset setting"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function startSocial(provider: "github" | "google" | "discord" | "microsoft") {
|
||||
if (isDemo) {
|
||||
setAuthMessage("Demo mode is active — social auth is simulated.");
|
||||
@@ -1500,6 +1571,9 @@ export default function App() {
|
||||
name={name()}
|
||||
authMessage={authMessage()}
|
||||
authPending={authPending()}
|
||||
canSignUp={canSignUp()}
|
||||
bootstrapRequired={instancePublic()?.bootstrap_required ?? false}
|
||||
enabledProviders={enabledSocialProviders()}
|
||||
onModeChange={setMode}
|
||||
onEmailChange={setEmail}
|
||||
onPasswordChange={setPassword}
|
||||
@@ -1816,6 +1890,12 @@ export default function App() {
|
||||
|
||||
<Show when={activeView() === "settings"}>
|
||||
<SettingsPage
|
||||
isPlatformAdmin={isPlatformAdmin()}
|
||||
instanceSettings={instanceSettings()}
|
||||
instancePending={instancePending() || instanceSettings.loading}
|
||||
instanceMessage={instanceMessage()}
|
||||
onSaveInstanceSetting={(key, value) => void saveInstanceSetting(key, value)}
|
||||
onResetInstanceSetting={(key) => void resetInstanceSetting(key)}
|
||||
apiKeys={apiKeys()}
|
||||
apiKeyName={apiKeyName()}
|
||||
apiKeyScopes={apiKeyScopes()}
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
Document,
|
||||
IntegrationAnalytics,
|
||||
IntegrationTestResult,
|
||||
InstanceSetting,
|
||||
MeResponse,
|
||||
MetricPoint,
|
||||
OrganizationSummary,
|
||||
@@ -608,6 +609,19 @@ class DemoService {
|
||||
}));
|
||||
}
|
||||
async listAuthUsers() { await this.delay(); return authUsers; }
|
||||
|
||||
// Mirrors InstanceService.listInstanceSettings — representative registry
|
||||
// slice so demo mode can show the Instance settings tab.
|
||||
async listInstanceSettings(): Promise<InstanceSetting[]> {
|
||||
await this.delay();
|
||||
return [
|
||||
{ key: "auth.signup_enabled", type: "bool", secret: false, is_set: false, source: "default", value: false },
|
||||
{ key: "mail.from", type: "string", secret: false, is_set: false, source: "env", value: "Primora <[email protected]>" },
|
||||
{ key: "mail.smtp_host", type: "string", secret: false, is_set: true, source: "app", value: "mailpit" },
|
||||
{ key: "mail.smtp_password", type: "string", secret: true, is_set: false, source: "default" },
|
||||
{ key: "ratelimit.user_per_minute", type: "int", secret: false, is_set: false, source: "default", value: 240 },
|
||||
] as InstanceSetting[];
|
||||
}
|
||||
async listCollections() { await this.delay(); return collections; }
|
||||
async listDocuments() { await this.delay(); return documents; }
|
||||
|
||||
|
||||
@@ -18,6 +18,12 @@ interface LoginPageProps {
|
||||
name: string;
|
||||
authMessage: string;
|
||||
authPending: boolean;
|
||||
/** False when the instance closed public sign-up (and bootstrap is done). */
|
||||
canSignUp: boolean;
|
||||
/** True while no user exists — the first sign-up becomes instance admin. */
|
||||
bootstrapRequired: boolean;
|
||||
/** Social providers the instance offers (empty on self-hosted). */
|
||||
enabledProviders: string[];
|
||||
onModeChange: (mode: AuthMode) => void;
|
||||
onEmailChange: (value: string) => void;
|
||||
onPasswordChange: (value: string) => void;
|
||||
@@ -35,7 +41,9 @@ const socialProviders: { id: Provider; label: string; logo: (p: { class?: string
|
||||
];
|
||||
|
||||
export function LoginPage(props: LoginPageProps) {
|
||||
const isSignIn = () => props.mode === "sign-in";
|
||||
const isSignIn = () => !props.bootstrapRequired && props.mode === "sign-in";
|
||||
const providers = () =>
|
||||
socialProviders.filter((p) => props.enabledProviders.includes(p.id));
|
||||
|
||||
return (
|
||||
<div class="auth-wrap">
|
||||
@@ -70,36 +78,47 @@ export function LoginPage(props: LoginPageProps) {
|
||||
<span class="font-semibold tracking-tight" style="color: var(--text-1)">Primora</span>
|
||||
</div>
|
||||
<h1 style="font-size: 1.25rem; letter-spacing: -0.02em;">
|
||||
{isSignIn() ? "Sign in" : "Create your account"}
|
||||
{isSignIn() ? "Sign in" : props.bootstrapRequired ? "Create the admin account" : "Create your account"}
|
||||
</h1>
|
||||
<p class="mt-1" style="font-size: 0.8125rem; color: var(--text-3)">
|
||||
{isSignIn()
|
||||
? "Access your workspace"
|
||||
: "One account for the whole workspace"}
|
||||
: props.bootstrapRequired
|
||||
? "The first account becomes the instance administrator"
|
||||
: "One account for the whole workspace"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* mode toggle */}
|
||||
<div class="seg mb-6" role="tablist" aria-label="Authentication mode">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isSignIn()}
|
||||
class={`seg-item ${isSignIn() ? "active" : ""}`}
|
||||
onClick={() => props.onModeChange("sign-in")}
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={!isSignIn()}
|
||||
class={`seg-item ${!isSignIn() ? "active" : ""}`}
|
||||
onClick={() => props.onModeChange("sign-up")}
|
||||
>
|
||||
Sign up
|
||||
</button>
|
||||
</div>
|
||||
{/* mode toggle — hidden during bootstrap; sign-up tab hidden when closed */}
|
||||
<Show when={!props.bootstrapRequired}>
|
||||
<div class="seg mb-6" role="tablist" aria-label="Authentication mode">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isSignIn()}
|
||||
class={`seg-item ${isSignIn() ? "active" : ""}`}
|
||||
onClick={() => props.onModeChange("sign-in")}
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
<Show when={props.canSignUp}>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={!isSignIn()}
|
||||
class={`seg-item ${!isSignIn() ? "active" : ""}`}
|
||||
onClick={() => props.onModeChange("sign-up")}
|
||||
>
|
||||
Sign up
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.bootstrapRequired}>
|
||||
<div class="message message-info mb-6">
|
||||
This instance has no users yet. Create the first account — it becomes the administrator.
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<form onSubmit={props.onSubmit} class="space-y-4">
|
||||
<Show when={!isSignIn()}>
|
||||
@@ -143,29 +162,43 @@ export function LoginPage(props: LoginPageProps) {
|
||||
>
|
||||
<Show
|
||||
when={props.authPending}
|
||||
fallback={isSignIn() ? "Sign in" : "Create account"}
|
||||
fallback={
|
||||
isSignIn()
|
||||
? "Sign in"
|
||||
: props.bootstrapRequired
|
||||
? "Create admin account"
|
||||
: "Create account"
|
||||
}
|
||||
>
|
||||
<span class="spinner" /> Working…
|
||||
</Show>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="divider my-6">or continue with</div>
|
||||
<Show when={!props.canSignUp && !props.bootstrapRequired}>
|
||||
<p class="mt-4 text-center" style="font-size: 0.8125rem; color: var(--text-3)">
|
||||
Public sign-up is closed on this instance.
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
<div class="grid grid-cols-4 gap-2">
|
||||
{socialProviders.map((p) => (
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-icon w-full"
|
||||
onClick={() => props.onSocial(p.id)}
|
||||
disabled={props.authPending}
|
||||
title={`Continue with ${p.label}`}
|
||||
aria-label={`Continue with ${p.label}`}
|
||||
>
|
||||
<p.logo class="w-4 h-4" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Show when={providers().length > 0}>
|
||||
<div class="divider my-6">or continue with</div>
|
||||
|
||||
<div class={`grid gap-2 ${providers().length === 1 ? "grid-cols-1" : providers().length === 2 ? "grid-cols-2" : "grid-cols-4"}`}>
|
||||
{providers().map((p) => (
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-icon w-full"
|
||||
onClick={() => props.onSocial(p.id)}
|
||||
disabled={props.authPending}
|
||||
title={`Continue with ${p.label}`}
|
||||
aria-label={`Continue with ${p.label}`}
|
||||
>
|
||||
<p.logo class="w-4 h-4" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.onTryDemo}>
|
||||
<div class="divider my-6" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { For, Show, createSignal } from "solid-js";
|
||||
import type { ApiKey } from "@primora/api-client";
|
||||
import type { ApiKey, InstanceSetting } from "@primora/api-client";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Modal, ModalFooter } from "../components/Modal";
|
||||
import { Input } from "../components/Input";
|
||||
@@ -25,6 +25,12 @@ const API_KEY_SCOPES: Array<{ value: string; label: string; hint: string }> = [
|
||||
];
|
||||
|
||||
interface SettingsPageProps {
|
||||
isPlatformAdmin: boolean;
|
||||
instanceSettings?: InstanceSetting[];
|
||||
instancePending: boolean;
|
||||
instanceMessage: string;
|
||||
onSaveInstanceSetting: (key: string, value: unknown) => void;
|
||||
onResetInstanceSetting: (key: string) => void;
|
||||
apiKeys?: ApiKey[];
|
||||
apiKeyName: string;
|
||||
apiKeyScopes: string[];
|
||||
@@ -50,8 +56,27 @@ interface SettingsPageProps {
|
||||
formatDate: (value?: string | null) => string;
|
||||
}
|
||||
|
||||
const INSTANCE_GROUPS: { prefix: string; title: string; hint: string }[] = [
|
||||
{ prefix: "auth.", title: "Authentication", hint: "Who can create accounts on this instance." },
|
||||
{ prefix: "mail.", title: "Mail", hint: "Outgoing email — Resend or SMTP. In-app values override .env." },
|
||||
{ prefix: "ratelimit.", title: "Rate limits", hint: "Requests per minute, enforced by the API." },
|
||||
];
|
||||
|
||||
const SETTING_LABELS: Record<string, string> = {
|
||||
"auth.signup_enabled": "Public sign-up",
|
||||
"mail.from": "From address",
|
||||
"mail.resend_api_key": "Resend API key",
|
||||
"mail.smtp_host": "SMTP host",
|
||||
"mail.smtp_port": "SMTP port",
|
||||
"mail.smtp_user": "SMTP user",
|
||||
"mail.smtp_password": "SMTP password",
|
||||
"mail.smtp_secure": "SMTP TLS",
|
||||
"ratelimit.user_per_minute": "User requests / minute",
|
||||
"ratelimit.api_key_per_minute": "API key requests / minute",
|
||||
};
|
||||
|
||||
export function SettingsPage(props: SettingsPageProps) {
|
||||
const [tab, setTab] = createSignal<"api-keys" | "organization">("api-keys");
|
||||
const [tab, setTab] = createSignal<"api-keys" | "organization" | "instance">("api-keys");
|
||||
const [copied, setCopied] = createSignal(false);
|
||||
const [createOrgOpen, setCreateOrgOpen] = createSignal(false);
|
||||
const [confirmDeleteOrg, setConfirmDeleteOrg] = createSignal(false);
|
||||
@@ -94,6 +119,14 @@ export function SettingsPage(props: SettingsPageProps) {
|
||||
>
|
||||
Organization
|
||||
</button>
|
||||
<Show when={props.isPlatformAdmin}>
|
||||
<button
|
||||
class={`tab ${tab() === "instance" ? "active" : ""}`}
|
||||
onClick={() => setTab("instance")}
|
||||
>
|
||||
Instance
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* ---------------- API keys ---------------- */}
|
||||
@@ -322,6 +355,53 @@ export function SettingsPage(props: SettingsPageProps) {
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{/* ---------------- Instance (platform admin) ---------------- */}
|
||||
<Show when={tab() === "instance" && props.isPlatformAdmin}>
|
||||
<Show when={props.instanceMessage}>
|
||||
<div class="message message-neutral">{props.instanceMessage}</div>
|
||||
</Show>
|
||||
|
||||
<p class="text-xs text-text-3 mb-4">
|
||||
In-app values override the matching environment variables. Secrets are stored encrypted
|
||||
and never shown back.
|
||||
</p>
|
||||
|
||||
<For each={INSTANCE_GROUPS}>
|
||||
{(group) => {
|
||||
const rows = () => (props.instanceSettings ?? []).filter((s) => s.key.startsWith(group.prefix));
|
||||
return (
|
||||
<Show when={rows().length > 0}>
|
||||
<div class="card card-flush mb-4">
|
||||
<div class="card-header">
|
||||
<span class="card-header-title">{group.title}</span>
|
||||
<span class="card-header-description">{group.hint}</span>
|
||||
</div>
|
||||
<div class="divide-y" style="border-color:var(--border)">
|
||||
<For each={rows()}>
|
||||
{(setting) => (
|
||||
<SettingRow
|
||||
setting={setting}
|
||||
pending={props.instancePending}
|
||||
onSave={(value) => props.onSaveInstanceSetting(setting.key, value)}
|
||||
onReset={() => props.onResetInstanceSetting(setting.key)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Show when={!props.instanceSettings}>
|
||||
<div class="card empty-state">
|
||||
<div class="empty-state-icon"><IconKey class="w-5 h-5" /></div>
|
||||
<p class="empty-state-title">Loading instance settings</p>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{/* API key secret modal — shown while App holds a fresh secret */}
|
||||
<Show when={props.apiKeySecret}>
|
||||
<Modal
|
||||
@@ -412,3 +492,104 @@ export function SettingsPage(props: SettingsPageProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Values arrive as JSON strings ("true", "240") — decode per declared type. */
|
||||
function settingText(setting: InstanceSetting): string {
|
||||
if (setting.value === undefined || setting.value === null) return "";
|
||||
return String(setting.value);
|
||||
}
|
||||
|
||||
const SOURCE_BADGE: Record<string, "success" | "primary" | "neutral"> = {
|
||||
app: "success",
|
||||
env: "primary",
|
||||
default: "neutral",
|
||||
};
|
||||
|
||||
function SettingRow(props: {
|
||||
setting: InstanceSetting;
|
||||
pending: boolean;
|
||||
onSave: (value: unknown) => void;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
const s = () => props.setting;
|
||||
const [draft, setDraft] = createSignal(settingText(props.setting));
|
||||
|
||||
const dirty = () => {
|
||||
if (s().type === "bool") return false; // toggles save instantly
|
||||
return draft() !== settingText(s());
|
||||
};
|
||||
|
||||
const saveDraft = () => {
|
||||
if (s().type === "int") {
|
||||
const n = Number(draft());
|
||||
if (!Number.isInteger(n) || n < 0) return;
|
||||
props.onSave(n);
|
||||
return;
|
||||
}
|
||||
props.onSave(draft());
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="flex items-center gap-3 px-4 py-3 flex-wrap">
|
||||
<div class="flex-1" style="min-width:12rem">
|
||||
<div class="text-sm font-medium text-text-1">
|
||||
{SETTING_LABELS[s().key] ?? s().key}
|
||||
</div>
|
||||
<code class="text-xs text-text-3">{s().key}</code>
|
||||
</div>
|
||||
|
||||
<Badge variant={SOURCE_BADGE[s().source] ?? "neutral"}>
|
||||
{s().is_set ? "in-app" : s().source}
|
||||
</Badge>
|
||||
|
||||
<Show
|
||||
when={s().type === "bool"}
|
||||
fallback={
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
class="input input-sm"
|
||||
style="width:12rem"
|
||||
type={s().secret ? "password" : s().type === "int" ? "number" : "text"}
|
||||
min={s().type === "int" ? 0 : undefined}
|
||||
placeholder={s().secret ? (s().is_set ? "•••••••• (stored)" : "not set") : ""}
|
||||
value={draft()}
|
||||
onInput={(e) => setDraft(e.currentTarget.value)}
|
||||
disabled={props.pending}
|
||||
aria-label={SETTING_LABELS[s().key] ?? s().key}
|
||||
/>
|
||||
<button
|
||||
class="btn btn-secondary btn-sm"
|
||||
onClick={saveDraft}
|
||||
disabled={props.pending || !dirty()}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<label class="flex items-center gap-2 text-sm text-text-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="accent-[var(--accent)]"
|
||||
checked={settingText(s()) === "true"}
|
||||
disabled={props.pending}
|
||||
onChange={(e) => props.onSave(e.currentTarget.checked)}
|
||||
aria-label={SETTING_LABELS[s().key] ?? s().key}
|
||||
/>
|
||||
{settingText(s()) === "true" ? "Enabled" : "Disabled"}
|
||||
</label>
|
||||
</Show>
|
||||
|
||||
<Show when={s().is_set}>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
onClick={props.onReset}
|
||||
disabled={props.pending}
|
||||
title="Remove the in-app override — env/default applies again"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -69,6 +69,9 @@ export type { HealthStatus } from './models/HealthStatus';
|
||||
export { IngestEvent } from './models/IngestEvent';
|
||||
export type { IngestRequest } from './models/IngestRequest';
|
||||
export type { IngestResponse } from './models/IngestResponse';
|
||||
export type { InstancePublicConfig } from './models/InstancePublicConfig';
|
||||
export { InstanceSetting } from './models/InstanceSetting';
|
||||
export type { InstanceSettingsResponse } from './models/InstanceSettingsResponse';
|
||||
export { Integration } from './models/Integration';
|
||||
export type { IntegrationAnalytics } from './models/IntegrationAnalytics';
|
||||
export type { IntegrationID } from './models/IntegrationID';
|
||||
@@ -115,6 +118,7 @@ export type { UpdateBucketObjectRequest } from './models/UpdateBucketObjectReque
|
||||
export { UpdateBucketRequest } from './models/UpdateBucketRequest';
|
||||
export type { UpdateCollectionRequest } from './models/UpdateCollectionRequest';
|
||||
export type { UpdateDocumentRequest } from './models/UpdateDocumentRequest';
|
||||
export type { UpdateInstanceSettingRequest } from './models/UpdateInstanceSettingRequest';
|
||||
export { UpdateOrganizationMemberRoleRequest } from './models/UpdateOrganizationMemberRoleRequest';
|
||||
export type { UpdateOrganizationRequest } from './models/UpdateOrganizationRequest';
|
||||
export { UpdateProjectMemberRoleRequest } from './models/UpdateProjectMemberRoleRequest';
|
||||
@@ -192,6 +196,9 @@ export { $HealthStatus } from './schemas/$HealthStatus';
|
||||
export { $IngestEvent } from './schemas/$IngestEvent';
|
||||
export { $IngestRequest } from './schemas/$IngestRequest';
|
||||
export { $IngestResponse } from './schemas/$IngestResponse';
|
||||
export { $InstancePublicConfig } from './schemas/$InstancePublicConfig';
|
||||
export { $InstanceSetting } from './schemas/$InstanceSetting';
|
||||
export { $InstanceSettingsResponse } from './schemas/$InstanceSettingsResponse';
|
||||
export { $Integration } from './schemas/$Integration';
|
||||
export { $IntegrationAnalytics } from './schemas/$IntegrationAnalytics';
|
||||
export { $IntegrationID } from './schemas/$IntegrationID';
|
||||
@@ -238,6 +245,7 @@ export { $UpdateBucketObjectRequest } from './schemas/$UpdateBucketObjectRequest
|
||||
export { $UpdateBucketRequest } from './schemas/$UpdateBucketRequest';
|
||||
export { $UpdateCollectionRequest } from './schemas/$UpdateCollectionRequest';
|
||||
export { $UpdateDocumentRequest } from './schemas/$UpdateDocumentRequest';
|
||||
export { $UpdateInstanceSettingRequest } from './schemas/$UpdateInstanceSettingRequest';
|
||||
export { $UpdateOrganizationMemberRoleRequest } from './schemas/$UpdateOrganizationMemberRoleRequest';
|
||||
export { $UpdateOrganizationRequest } from './schemas/$UpdateOrganizationRequest';
|
||||
export { $UpdateProjectMemberRoleRequest } from './schemas/$UpdateProjectMemberRoleRequest';
|
||||
@@ -257,6 +265,7 @@ export { AutomationService } from './services/AutomationService';
|
||||
export { CollectionsService } from './services/CollectionsService';
|
||||
export { DatabasesService } from './services/DatabasesService';
|
||||
export { HealthService } from './services/HealthService';
|
||||
export { InstanceService } from './services/InstanceService';
|
||||
export { IntegrationsService } from './services/IntegrationsService';
|
||||
export { OrganizationsService } from './services/OrganizationsService';
|
||||
export { PlatformService } from './services/PlatformService';
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export type InstancePublicConfig = {
|
||||
/**
|
||||
* Whether public sign-up is open (bootstrap always allowed)
|
||||
*/
|
||||
signup_enabled: boolean;
|
||||
/**
|
||||
* True when no user exists yet — first sign-up becomes instance admin
|
||||
*/
|
||||
bootstrap_required: boolean;
|
||||
/**
|
||||
* Enabled OAuth providers — empty on self-hosted
|
||||
*/
|
||||
social_providers: Array<string>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export type InstanceSetting = {
|
||||
key: string;
|
||||
type: InstanceSetting.type;
|
||||
secret: boolean;
|
||||
/**
|
||||
* True when an in-app override exists
|
||||
*/
|
||||
is_set: boolean;
|
||||
source: InstanceSetting.source;
|
||||
/**
|
||||
* Effective value — never returned for secret keys
|
||||
*/
|
||||
value?: any;
|
||||
};
|
||||
export namespace InstanceSetting {
|
||||
export enum type {
|
||||
BOOL = 'bool',
|
||||
INT = 'int',
|
||||
STRING = 'string',
|
||||
}
|
||||
export enum source {
|
||||
APP = 'app',
|
||||
ENV = 'env',
|
||||
DEFAULT = 'default',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
import type { InstanceSetting } from './InstanceSetting';
|
||||
export type InstanceSettingsResponse = {
|
||||
settings: Array<InstanceSetting>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export type UpdateInstanceSettingRequest = {
|
||||
/**
|
||||
* JSON value matching the key's declared type
|
||||
*/
|
||||
value: any;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const $InstancePublicConfig = {
|
||||
properties: {
|
||||
signup_enabled: {
|
||||
type: 'boolean',
|
||||
description: `Whether public sign-up is open (bootstrap always allowed)`,
|
||||
isRequired: true,
|
||||
},
|
||||
bootstrap_required: {
|
||||
type: 'boolean',
|
||||
description: `True when no user exists yet — first sign-up becomes instance admin`,
|
||||
isRequired: true,
|
||||
},
|
||||
social_providers: {
|
||||
type: 'array',
|
||||
contains: {
|
||||
type: 'string',
|
||||
},
|
||||
isRequired: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,34 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const $InstanceSetting = {
|
||||
properties: {
|
||||
key: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
},
|
||||
type: {
|
||||
type: 'Enum',
|
||||
isRequired: true,
|
||||
},
|
||||
secret: {
|
||||
type: 'boolean',
|
||||
isRequired: true,
|
||||
},
|
||||
is_set: {
|
||||
type: 'boolean',
|
||||
description: `True when an in-app override exists`,
|
||||
isRequired: true,
|
||||
},
|
||||
source: {
|
||||
type: 'Enum',
|
||||
isRequired: true,
|
||||
},
|
||||
value: {
|
||||
description: `Effective value — never returned for secret keys`,
|
||||
properties: {
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,15 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const $InstanceSettingsResponse = {
|
||||
properties: {
|
||||
settings: {
|
||||
type: 'array',
|
||||
contains: {
|
||||
type: 'InstanceSetting',
|
||||
},
|
||||
isRequired: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,14 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const $UpdateInstanceSettingRequest = {
|
||||
properties: {
|
||||
value: {
|
||||
description: `JSON value matching the key's declared type`,
|
||||
properties: {
|
||||
},
|
||||
isRequired: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,88 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
import type { InstancePublicConfig } from '../models/InstancePublicConfig';
|
||||
import type { InstanceSetting } from '../models/InstanceSetting';
|
||||
import type { InstanceSettingsResponse } from '../models/InstanceSettingsResponse';
|
||||
import type { UpdateInstanceSettingRequest } from '../models/UpdateInstanceSettingRequest';
|
||||
import type { CancelablePromise } from '../core/CancelablePromise';
|
||||
import { OpenAPI } from '../core/OpenAPI';
|
||||
import { request as __request } from '../core/request';
|
||||
export class InstanceService {
|
||||
/**
|
||||
* Public instance state for the login page — no auth required
|
||||
* @returns InstancePublicConfig Public instance configuration
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static getInstancePublic(): CancelablePromise<InstancePublicConfig> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'GET',
|
||||
url: '/instance/public',
|
||||
});
|
||||
}
|
||||
/**
|
||||
* List instance settings (platform admin only)
|
||||
* @returns InstanceSettingsResponse Resolved instance settings
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static listInstanceSettings(): CancelablePromise<InstanceSettingsResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'GET',
|
||||
url: '/instance/settings',
|
||||
errors: {
|
||||
401: `Error response`,
|
||||
403: `Error response`,
|
||||
},
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Set an instance setting override (platform admin only)
|
||||
* @returns InstanceSetting Setting stored
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static updateInstanceSetting({
|
||||
key,
|
||||
requestBody,
|
||||
}: {
|
||||
key: string,
|
||||
requestBody: UpdateInstanceSettingRequest,
|
||||
}): CancelablePromise<InstanceSetting> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'PUT',
|
||||
url: '/instance/settings/{key}',
|
||||
path: {
|
||||
'key': key,
|
||||
},
|
||||
body: requestBody,
|
||||
mediaType: 'application/json',
|
||||
errors: {
|
||||
400: `Error response`,
|
||||
403: `Error response`,
|
||||
404: `Error response`,
|
||||
},
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Revert a setting to its env/default value (platform admin only)
|
||||
* @returns void
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static deleteInstanceSetting({
|
||||
key,
|
||||
}: {
|
||||
key: string,
|
||||
}): CancelablePromise<void> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'DELETE',
|
||||
url: '/instance/settings/{key}',
|
||||
path: {
|
||||
'key': key,
|
||||
},
|
||||
errors: {
|
||||
403: `Error response`,
|
||||
404: `Error response`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user