(() =>
parseOrgPickerEmbedOptions(location.search),
);
- const pickerSrc = buildOrgPickerEmbedSrc(options);
+ const pickerSrcBase = buildOrgPickerEmbedSrc(options);
+ const pickerSrc = shareToken
+ ? `${pickerSrcBase}&token=${encodeURIComponent(shareToken)}`
+ : pickerSrcBase;
return (
diff --git a/orgfront/tests/orgchart-picker.spec.ts b/orgfront/tests/orgchart-picker.spec.ts
index f55056db..7fbf98ae 100644
--- a/orgfront/tests/orgchart-picker.spec.ts
+++ b/orgfront/tests/orgchart-picker.spec.ts
@@ -1,5 +1,13 @@
import { expect, test } from "@playwright/test";
+const shareToken = "playwright";
+
+function withShareToken(path: string) {
+ return path.includes("?")
+ ? `${path}&token=${shareToken}`
+ : `${path}?token=${shareToken}`;
+}
+
type TenantFixture = {
id: string;
type: string;
@@ -75,41 +83,58 @@ async function seedOrgfrontAuth(page: Parameters[0]["page"]) {
},
expires_at: issuedAt + 3600,
};
-
- window.localStorage.setItem(
+ const storageKeys = [
+ "user:http://localhost:5000/oidc:orgfront",
+ "user:http://localhost:5000/oidc/:orgfront",
+ "user:http://localhost:5000/oidc:devfront",
+ "user:http://localhost:5000/oidc/:devfront",
+ "user:http://172.16.9.189:5000/oidc:orgfront",
+ "user:http://172.16.9.189:5000/oidc/:orgfront",
"oidc.user:http://localhost:5000/oidc:orgfront",
- JSON.stringify(mockOidcUser),
- );
- window.localStorage.setItem(
"oidc.user:http://localhost:5000/oidc/:orgfront",
- JSON.stringify(mockOidcUser),
- );
- window.localStorage.setItem(
"oidc.user:http://localhost:5000/oidc:devfront",
- JSON.stringify(mockOidcUser),
- );
- window.localStorage.setItem(
"oidc.user:http://localhost:5000/oidc/:devfront",
- JSON.stringify(mockOidcUser),
- );
- window.localStorage.setItem(
"oidc.user:http://172.16.9.189:5000/oidc:orgfront",
- JSON.stringify(mockOidcUser),
- );
- window.localStorage.setItem(
"oidc.user:http://172.16.9.189:5000/oidc/:orgfront",
- JSON.stringify(mockOidcUser),
- );
+ ];
+ for (const key of storageKeys) {
+ window.localStorage.setItem(key, JSON.stringify(mockOidcUser));
+ }
+ window.localStorage.setItem("playwright_auth_bypass", "1");
window.localStorage.setItem("dev_tenant_id", "group-hmac");
},
{ issuedAt: nowInSeconds },
);
await page.route("**/oidc/**", async (route) => {
+ const url = route.request().url();
+ if (url.includes(".well-known/openid-configuration")) {
+ await route.fulfill({
+ json: {
+ issuer: "http://localhost:5000/oidc",
+ authorization_endpoint: "http://localhost:5000/oidc/auth",
+ token_endpoint: "http://localhost:5000/oidc/token",
+ jwks_uri: "http://localhost:5000/oidc/jwks",
+ userinfo_endpoint: "http://localhost:5000/oidc/userinfo",
+ end_session_endpoint: "http://localhost:5000/oidc/session/end",
+ },
+ headers: { "Access-Control-Allow-Origin": "*" },
+ });
+ return;
+ }
+
+ if (url.includes("/jwks")) {
+ await route.fulfill({
+ json: { keys: [] },
+ headers: { "Access-Control-Allow-Origin": "*" },
+ });
+ return;
+ }
+
await route.fulfill({
status: 200,
- contentType: "application/json",
- body: JSON.stringify({ keys: [] }),
+ body: "ok",
+ headers: { "Access-Control-Allow-Origin": "*" },
});
});
}
@@ -186,7 +211,7 @@ test.beforeEach(async ({ page }) => {
test("developer navigation exposes chart, picker, and embed preview", async ({
page,
}) => {
- await page.goto("/");
+ await page.goto(withShareToken("/chart"));
await expect(page.getByRole("link", { name: "조직도" })).toBeVisible();
await expect(page.getByRole("link", { name: "조직 선택기" })).toBeVisible();
@@ -207,7 +232,7 @@ test("developer navigation exposes chart, picker, and embed preview", async ({
test("picker menu lets developers switch selection mode and selectable type", async ({
page,
}) => {
- await page.goto("/picker");
+ await page.goto(withShareToken("/picker"));
await expect(page.getByLabel("선택 모드")).toHaveValue("multiple");
await expect(page.getByLabel("선택 대상")).toHaveValue("both");
@@ -230,7 +255,7 @@ test("picker menu lets developers switch selection mode and selectable type", as
test("picker displays user names with job title and position", async ({
page,
}) => {
- await page.goto("/embed/picker?mode=single&select=user");
+ await page.goto(withShareToken("/embed/picker?mode=single&select=user"));
await expect(
page.getByRole("button", {
@@ -242,7 +267,7 @@ test("picker displays user names with job title and position", async ({
test("embed preview menu updates the iframe picker source", async ({
page,
}) => {
- await page.goto("/embed-preview");
+ await page.goto(withShareToken("/embed-preview"));
await expect(page.getByLabel("선택 모드")).toHaveValue("multiple");
await expect(page.getByLabel("선택 대상")).toHaveValue("both");
@@ -297,7 +322,7 @@ test("embed preview menu updates the iframe picker source", async ({
test("embed preview passes tenant id and custom dimensions through the picker url", async ({
page,
}) => {
- await page.goto("/embed-preview");
+ await page.goto(withShareToken("/embed-preview"));
await page.getByLabel("tenant ID").fill("company-baron");
await page.getByLabel("임베딩 너비").fill("520");
@@ -325,7 +350,9 @@ test("embed preview passes tenant id and custom dimensions through the picker ur
test("embed picker scopes the tree by tenant id, hides users for tenant selection, and keeps direct members before child tenants", async ({
page,
}) => {
- await page.goto("/embed-preview?tenantId=company-baron&select=tenant");
+ await page.goto(
+ withShareToken("/embed-preview?tenantId=company-baron&select=tenant"),
+ );
await expect(page.getByLabel("tenant ID")).toHaveValue("company-baron");
await expect(page.getByTestId("embed-preview-src")).toContainText(
@@ -352,7 +379,7 @@ test("embed picker scopes the tree by tenant id, hides users for tenant selectio
test("embed picker keeps the lightweight search controls inside the picker section at the default embed width", async ({
page,
}) => {
- await page.goto("/embed-preview");
+ await page.goto(withShareToken("/embed-preview"));
const picker = page.frameLocator("iframe");
const searchSection = picker.getByTestId("org-picker-search-section");
@@ -379,7 +406,7 @@ test("embed picker keeps the lightweight search controls inside the picker secti
test("embed picker keeps only the lightweight picker surface scrollable", async ({
page,
}) => {
- await page.goto("/embed-preview");
+ await page.goto(withShareToken("/embed-preview"));
const picker = page.frameLocator("iframe");
await expect(
@@ -415,7 +442,7 @@ test("embed picker keeps only the lightweight picker surface scrollable", async
test("embed preview can hide the descendant selection switch", async ({
page,
}) => {
- await page.goto("/embed-preview?mode=multiple&select=both");
+ await page.goto(withShareToken("/embed-preview?mode=multiple&select=both"));
await expect(page.getByLabel("하위 선택 스위치 표시")).toBeChecked();
await page.getByLabel("하위 선택 스위치 표시").uncheck();
@@ -434,7 +461,7 @@ test("embed preview can hide the descendant selection switch", async ({
test("embed picker renders compact tree rows with member emails", async ({
page,
}) => {
- await page.goto("/embed-preview?mode=single&select=user");
+ await page.goto(withShareToken("/embed-preview?mode=single&select=user"));
const picker = page.frameLocator("iframe");
await expect(picker.getByText("user-eng@example.com")).toBeVisible();
@@ -451,7 +478,7 @@ test("embed picker renders compact tree rows with member emails", async ({
test("embed picker filters organizations and users by id, name, and metadata", async ({
page,
}) => {
- await page.goto("/embed-preview?mode=multiple&select=both");
+ await page.goto(withShareToken("/embed-preview?mode=multiple&select=both"));
const picker = page.frameLocator("iframe");
const search = picker.getByLabel("조직/구성원 검색");
@@ -475,7 +502,7 @@ test("embed picker filters organizations and users by id, name, and metadata", a
test("embed picker search does not keep unmatched descendants under a matching organization", async ({
page,
}) => {
- await page.goto("/embed-preview?mode=multiple&select=both");
+ await page.goto(withShareToken("/embed-preview?mode=multiple&select=both"));
const picker = page.frameLocator("iframe");
await picker.getByLabel("조직/구성원 검색").fill("센");
@@ -489,7 +516,7 @@ test("embed picker search does not keep unmatched descendants under a matching o
test("embed picker posts a single user selection with type, id, and name", async ({
page,
}) => {
- await page.goto("/embed-preview?mode=single&select=user");
+ await page.goto(withShareToken("/embed-preview?mode=single&select=user"));
const picker = page.frameLocator("iframe");
await picker
@@ -507,7 +534,7 @@ test("embed picker posts a single user selection with type, id, and name", async
test("embed picker single selection counts only the selected node without descendants", async ({
page,
}) => {
- await page.goto("/embed-preview?mode=single&select=both");
+ await page.goto(withShareToken("/embed-preview?mode=single&select=both"));
const picker = page.frameLocator("iframe");
await picker
@@ -528,7 +555,7 @@ test("embed picker single selection counts only the selected node without descen
test("embed picker highlights a single selected item without tree connectors", async ({
page,
}) => {
- await page.goto("/embed-preview?mode=single&select=both");
+ await page.goto(withShareToken("/embed-preview?mode=single&select=both"));
const picker = page.frameLocator("iframe");
await expect(
@@ -548,7 +575,7 @@ test("embed picker highlights a single selected item without tree connectors", a
test("embed picker renders tenant names with the dedicated tenant text color", async ({
page,
}) => {
- await page.goto("/embed-preview?mode=single&select=both");
+ await page.goto(withShareToken("/embed-preview?mode=single&select=both"));
const picker = page.frameLocator("iframe");
const tenantName = picker.getByTestId("org-picker-node-name-tenant").first();
@@ -563,7 +590,7 @@ test("embed picker renders tenant names with the dedicated tenant text color", a
test("embed picker includes descendants by default and can disable descendant inclusion", async ({
page,
}) => {
- await page.goto("/embed-preview?mode=multiple&select=both");
+ await page.goto(withShareToken("/embed-preview?mode=multiple&select=both"));
let picker = page.frameLocator("iframe");
await expect(
@@ -582,7 +609,9 @@ test("embed picker includes descendants by default and can disable descendant in
await expect(output).toContainText('"id": "user-platform"');
await page.goto(
- "/embed-preview?mode=multiple&select=both&includeDescendants=false",
+ withShareToken(
+ "/embed-preview?mode=multiple&select=both&includeDescendants=false",
+ ),
);
picker = page.frameLocator("iframe");
await picker.getByLabel("Engineering 선택").check();
diff --git a/orgfront/tests/orgchart-vector-render.spec.ts b/orgfront/tests/orgchart-vector-render.spec.ts
index 7948a492..bfd55ae0 100644
--- a/orgfront/tests/orgchart-vector-render.spec.ts
+++ b/orgfront/tests/orgchart-vector-render.spec.ts
@@ -214,41 +214,58 @@ test("org chart places multi-tenant users only on leaf memberships without dupli
},
expires_at: seededIssuedAt + 3600,
};
-
- window.localStorage.setItem(
+ const storageKeys = [
+ "user:http://localhost:5000/oidc:orgfront",
+ "user:http://localhost:5000/oidc/:orgfront",
+ "user:http://localhost:5000/oidc:devfront",
+ "user:http://localhost:5000/oidc/:devfront",
+ "user:http://172.16.9.189:5000/oidc:orgfront",
+ "user:http://172.16.9.189:5000/oidc/:orgfront",
"oidc.user:http://localhost:5000/oidc:orgfront",
- JSON.stringify(mockOidcUser),
- );
- window.localStorage.setItem(
"oidc.user:http://localhost:5000/oidc/:orgfront",
- JSON.stringify(mockOidcUser),
- );
- window.localStorage.setItem(
"oidc.user:http://localhost:5000/oidc:devfront",
- JSON.stringify(mockOidcUser),
- );
- window.localStorage.setItem(
"oidc.user:http://localhost:5000/oidc/:devfront",
- JSON.stringify(mockOidcUser),
- );
- window.localStorage.setItem(
"oidc.user:http://172.16.9.189:5000/oidc:orgfront",
- JSON.stringify(mockOidcUser),
- );
- window.localStorage.setItem(
"oidc.user:http://172.16.9.189:5000/oidc/:orgfront",
- JSON.stringify(mockOidcUser),
- );
+ ];
+ for (const key of storageKeys) {
+ window.localStorage.setItem(key, JSON.stringify(mockOidcUser));
+ }
+ window.localStorage.setItem("playwright_auth_bypass", "1");
window.localStorage.setItem("dev_tenant_id", "group");
},
{ issuedAt },
);
await page.route("**/oidc/**", async (route) => {
+ const url = route.request().url();
+ if (url.includes(".well-known/openid-configuration")) {
+ await route.fulfill({
+ json: {
+ issuer: "http://localhost:5000/oidc",
+ authorization_endpoint: "http://localhost:5000/oidc/auth",
+ token_endpoint: "http://localhost:5000/oidc/token",
+ jwks_uri: "http://localhost:5000/oidc/jwks",
+ userinfo_endpoint: "http://localhost:5000/oidc/userinfo",
+ end_session_endpoint: "http://localhost:5000/oidc/session/end",
+ },
+ headers: { "Access-Control-Allow-Origin": "*" },
+ });
+ return;
+ }
+
+ if (url.includes("/jwks")) {
+ await route.fulfill({
+ json: { keys: [] },
+ headers: { "Access-Control-Allow-Origin": "*" },
+ });
+ return;
+ }
+
await route.fulfill({
status: 200,
- contentType: "application/json",
- body: JSON.stringify({ keys: [] }),
+ body: "ok",
+ headers: { "Access-Control-Allow-Origin": "*" },
});
});
diff --git a/orgfront/tests/orgfront-auto-login.spec.ts b/orgfront/tests/orgfront-auto-login.spec.ts
index 3fda2cb6..e15b4eb5 100644
--- a/orgfront/tests/orgfront-auto-login.spec.ts
+++ b/orgfront/tests/orgfront-auto-login.spec.ts
@@ -56,7 +56,7 @@ test("orgfront login auto parameter starts OIDC authorization", async ({
const parsed = new URL(oidc.authorizationURL());
expect(parsed.searchParams.get("client_id")).toBe("orgfront");
expect(parsed.searchParams.get("redirect_uri")).toBe(
- "http://localhost:5175/auth/callback",
+ "http://127.0.0.1:4175/auth/callback",
);
expect(parsed.searchParams.get("response_type")).toBe("code");
expect(parsed.searchParams.get("scope") ?? "").toContain("openid");
diff --git a/scripts/auth_config.sh b/scripts/auth_config.sh
index 735320ed..dfe3f60e 100755
--- a/scripts/auth_config.sh
+++ b/scripts/auth_config.sh
@@ -2,7 +2,7 @@
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-OUTPUT_DIR="$ROOT_DIR/.generated"
+OUTPUT_DIR="$ROOT_DIR/config/.generated"
OUTPUT_FILE="$OUTPUT_DIR/auth-config.env"
MODE="${1:-build}"
@@ -16,11 +16,12 @@ fi
USERFRONT_URL="${USERFRONT_URL:-http://localhost:5000}"
OATHKEEPER_PUBLIC_URL="${OATHKEEPER_PUBLIC_URL:-$USERFRONT_URL}"
HYDRA_PUBLIC_URL="${HYDRA_PUBLIC_URL:-${OATHKEEPER_PUBLIC_URL%/}/oidc}"
+HYDRA_ADMIN_URL="${HYDRA_ADMIN_URL:-http://hydra:4445}"
KRATOS_UI_URL="${KRATOS_UI_URL:-http://localhost:5000}"
ADMINFRONT_URL="${ADMINFRONT_URL:-https://sadmin.hmac.kr}"
DEVFRONT_URL="${DEVFRONT_URL:-https://sdev.hmac.kr}"
-ADMINFRONT_CALLBACK_URLS="${ADMINFRONT_CALLBACK_URLS:-http://172.16.10.176:5173/auth/callback}"
-DEVFRONT_CALLBACK_URLS="${DEVFRONT_CALLBACK_URLS:-http://172.16.10.176:5174/auth/callback}"
+ADMINFRONT_CALLBACK_URLS="${ADMINFRONT_CALLBACK_URLS:-${ADMINFRONT_URL%/}/auth/callback}"
+DEVFRONT_CALLBACK_URLS="${DEVFRONT_CALLBACK_URLS:-${DEVFRONT_URL%/}/auth/callback}"
KRATOS_ALLOWED_RETURN_URLS_EXTRA="${KRATOS_ALLOWED_RETURN_URLS_EXTRA:-}"
declare -a WARNINGS=()
@@ -258,13 +259,10 @@ validate_gateway_mapping() {
if ! grep -Eq 'location /oidc' "$ROOT_DIR/gateway/nginx.conf"; then
mode="unmapped_fail"
fi
- if ! grep -Eq 'rewrite \^/oidc/\(\.\*\)\$ /\$1 break;' "$ROOT_DIR/gateway/nginx.conf"; then
+ if ! grep -Eq '"url": "<\.\*>://<(\.\*|\[\^/\]\+)>/oidc/oauth2/<\.\*>"' "$ROOT_DIR/docker/ory/oathkeeper/rules.json"; then
mode="unmapped_fail"
fi
- if ! grep -Eq '"url": "<\.\*>://<\.\*>/oidc/oauth2/<\.\*>"' "$ROOT_DIR/docker/ory/oathkeeper/rules.json"; then
- mode="unmapped_fail"
- fi
- if ! grep -Eq '"strip_path_prefix": "/oidc"' "$ROOT_DIR/docker/ory/oathkeeper/rules.json"; then
+ if ! grep -Eq '"strip_path(_prefix)?": "/oidc"' "$ROOT_DIR/docker/ory/oathkeeper/rules.json"; then
mode="unmapped_fail"
fi
fi
@@ -358,10 +356,10 @@ verify_runtime_hydra_clients() {
fi
local admin_info dev_info
- if ! admin_info="$(docker exec ory_hydra hydra get oauth2-client --endpoint http://hydra:4445 adminfront 2>/dev/null)"; then
+ if ! admin_info="$(docker exec ory_hydra hydra get oauth2-client --endpoint "$HYDRA_ADMIN_URL" adminfront 2>/dev/null)"; then
fail "failed to read hydra client 'adminfront' from running container"
fi
- if ! dev_info="$(docker exec ory_hydra hydra get oauth2-client --endpoint http://hydra:4445 devfront 2>/dev/null)"; then
+ if ! dev_info="$(docker exec ory_hydra hydra get oauth2-client --endpoint "$HYDRA_ADMIN_URL" devfront 2>/dev/null)"; then
fail "failed to read hydra client 'devfront' from running container"
fi
@@ -382,6 +380,7 @@ run_validation() {
validate_dotenv_line_safety "BACKEND_URL"
validate_dotenv_line_safety "OATHKEEPER_PUBLIC_URL"
validate_dotenv_line_safety "HYDRA_PUBLIC_URL"
+ validate_dotenv_line_safety "HYDRA_ADMIN_URL"
validate_dotenv_line_safety "KRATOS_BROWSER_URL"
validate_dotenv_line_safety "KRATOS_UI_URL"
validate_dotenv_line_safety "ADMINFRONT_URL"
diff --git a/scripts/render_ory_config.sh b/scripts/render_ory_config.sh
new file mode 100755
index 00000000..d2487101
--- /dev/null
+++ b/scripts/render_ory_config.sh
@@ -0,0 +1,100 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+OUTPUT_DIR="${ORY_CONFIG_OUTPUT_DIR:-$ROOT_DIR/config/.generated/ory}"
+TEMPLATE_ROOT="${ORY_CONFIG_TEMPLATE_ROOT:-$ROOT_DIR/docker/ory}"
+
+load_env_file() {
+ local env_file="$1"
+ if [[ -f "$env_file" ]]; then
+ set -a
+ # shellcheck disable=SC1090
+ source "$env_file"
+ set +a
+ fi
+}
+
+fail() {
+ echo "[ory-config] ERROR: $1" >&2
+ exit 1
+}
+
+render_template() {
+ local src="$1"
+ local dst="$2"
+ mkdir -p "$(dirname "$dst")"
+ perl -pe '
+ s/\$\{([A-Za-z_][A-Za-z0-9_]*)(:-([^}]*))?\}/
+ exists $ENV{$1} ? $ENV{$1} : defined $3 ? $3 : die "missing env var: $1\n"
+ /gex
+ ' "$src" > "$dst"
+}
+
+copy_if_exists() {
+ local src="$1"
+ local dst="$2"
+ if [[ -e "$src" ]]; then
+ mkdir -p "$(dirname "$dst")"
+ cp -a "$src" "$dst"
+ fi
+}
+
+if [[ -n "${ORY_CONFIG_ENV_FILES:-}" ]]; then
+ IFS=':' read -r -a env_files <<<"$ORY_CONFIG_ENV_FILES"
+ for env_file in "${env_files[@]}"; do
+ load_env_file "$env_file"
+ done
+else
+ load_env_file "$ROOT_DIR/.env"
+ load_env_file "$ROOT_DIR/config/.generated/auth-config.env"
+fi
+
+ORY_POSTGRES_USER="${ORY_POSTGRES_USER:-ory}"
+ORY_POSTGRES_PASSWORD="${ORY_POSTGRES_PASSWORD:-secret}"
+KRATOS_DB="${KRATOS_DB:-ory_kratos}"
+HYDRA_DB="${HYDRA_DB:-ory_hydra}"
+KETO_DB="${KETO_DB:-ory_keto}"
+KRATOS_DSN="${KRATOS_DSN:-postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB}?sslmode=disable&max_conns=20}"
+HYDRA_DSN="${HYDRA_DSN:-postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB}?sslmode=disable&max_conns=20}"
+KETO_DSN="${KETO_DSN:-postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB}?sslmode=disable&max_conns=20}"
+HYDRA_SYSTEM_SECRET="${HYDRA_SYSTEM_SECRET:-${SECRETS_SYSTEM:-${ORY_POSTGRES_PASSWORD}}}"
+OATHKEEPER_INTROSPECT_CLIENT_ID="${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}"
+OATHKEEPER_INTROSPECT_CLIENT_SECRET="${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret}"
+
+export KRATOS_DSN HYDRA_DSN KETO_DSN HYDRA_SYSTEM_SECRET
+export OATHKEEPER_INTROSPECT_CLIENT_ID OATHKEEPER_INTROSPECT_CLIENT_SECRET
+
+rm -rf "$OUTPUT_DIR"
+mkdir -p "$OUTPUT_DIR"
+
+render_template "$TEMPLATE_ROOT/kratos/kratos.yml.template" "$OUTPUT_DIR/kratos/kratos.yml"
+copy_if_exists "$TEMPLATE_ROOT/kratos/identity.schema.json" "$OUTPUT_DIR/kratos/identity.schema.json"
+copy_if_exists "$TEMPLATE_ROOT/kratos/courier-http.jsonnet" "$OUTPUT_DIR/kratos/courier-http.jsonnet"
+if [[ -d "$TEMPLATE_ROOT/kratos/courier-templates" ]]; then
+ mkdir -p "$OUTPUT_DIR/kratos"
+ cp -a "$TEMPLATE_ROOT/kratos/courier-templates" "$OUTPUT_DIR/kratos/courier-templates"
+fi
+
+render_template "$TEMPLATE_ROOT/hydra/hydra.yml.template" "$OUTPUT_DIR/hydra/hydra.yml"
+
+render_template "$TEMPLATE_ROOT/keto/keto.yml.template" "$OUTPUT_DIR/keto/keto.yml"
+copy_if_exists "$TEMPLATE_ROOT/keto/namespaces.ts" "$OUTPUT_DIR/keto/namespaces.ts"
+copy_if_exists "$TEMPLATE_ROOT/keto/namespaces.yml" "$OUTPUT_DIR/keto/namespaces.yml"
+
+render_template "$TEMPLATE_ROOT/oathkeeper/oathkeeper.yml.template" "$OUTPUT_DIR/oathkeeper/oathkeeper.yml"
+copy_if_exists "$TEMPLATE_ROOT/oathkeeper/entrypoint.sh" "$OUTPUT_DIR/oathkeeper/entrypoint.sh"
+chmod +x "$OUTPUT_DIR/oathkeeper/entrypoint.sh"
+for rules_file in "$TEMPLATE_ROOT"/oathkeeper/rules*.json; do
+ [[ -e "$rules_file" ]] || continue
+ copy_if_exists "$rules_file" "$OUTPUT_DIR/oathkeeper/$(basename "$rules_file")"
+done
+
+if find "$OUTPUT_DIR" -type f \( -name '*.yml' -o -name '*.yaml' -o -name '*.json' -o -name '*.toml' \) -print0 | xargs -0 grep -n '\${' >/tmp/ory-render-unresolved.$$ 2>/dev/null; then
+ cat /tmp/ory-render-unresolved.$$ >&2
+ rm -f /tmp/ory-render-unresolved.$$
+ fail "rendered Ory config contains unresolved placeholders"
+fi
+rm -f /tmp/ory-render-unresolved.$$
+
+echo "[ory-config] wrote: $OUTPUT_DIR"
diff --git a/test/backend_go_version_policy_test.sh b/test/backend_go_version_policy_test.sh
new file mode 100644
index 00000000..64072168
--- /dev/null
+++ b/test/backend_go_version_policy_test.sh
@@ -0,0 +1,78 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+TARGET_GO_VERSION="1.26.2"
+
+GO_MOD="$ROOT_DIR/backend/go.mod"
+BACKEND_DOCKERFILE="$ROOT_DIR/backend/Dockerfile"
+LOCAL_COMPOSE="$ROOT_DIR/docker-compose.yaml"
+STAGING_COMPOSE="$ROOT_DIR/docker/docker-compose.staging.template.yaml"
+PULL_COMPOSE="$ROOT_DIR/docker/staging_pull_compose.template.yaml"
+DEPLOY_TEMPLATE="$ROOT_DIR/deploy/templates/docker-compose.yaml"
+README="$ROOT_DIR/README.md"
+README_EN="$ROOT_DIR/README_en.md"
+TEST_GUIDE="$ROOT_DIR/docs/TEST_GUIDE.md"
+COMPLETION_REPORT="$ROOT_DIR/docs/개발완료보고서.md"
+
+for file in \
+ "$GO_MOD" \
+ "$BACKEND_DOCKERFILE" \
+ "$LOCAL_COMPOSE" \
+ "$STAGING_COMPOSE" \
+ "$PULL_COMPOSE" \
+ "$DEPLOY_TEMPLATE" \
+ "$README" \
+ "$README_EN" \
+ "$TEST_GUIDE" \
+ "$COMPLETION_REPORT"
+do
+ if [[ ! -f "$file" ]]; then
+ echo "ERROR: expected file not found: $file" >&2
+ exit 1
+ fi
+done
+
+if ! grep -Eq "^go ${TARGET_GO_VERSION}$" "$GO_MOD"; then
+ echo "ERROR: backend go.mod must use go ${TARGET_GO_VERSION}." >&2
+ exit 1
+fi
+
+if ! grep -Eq "^FROM golang:${TARGET_GO_VERSION}-alpine$" "$BACKEND_DOCKERFILE"; then
+ echo "ERROR: backend Dockerfile must use golang:${TARGET_GO_VERSION}-alpine." >&2
+ exit 1
+fi
+
+for file in "$LOCAL_COMPOSE" "$PULL_COMPOSE"; do
+ if ! grep -Fq "context: ./backend" "$file" && ! grep -Fq "context: ../../backend" "$file"; then
+ echo "ERROR: backend compose build context is missing in $file." >&2
+ exit 1
+ fi
+done
+
+for file in "$STAGING_COMPOSE" "$DEPLOY_TEMPLATE"; do
+ if ! grep -Eq "^[[:space:]]+backend:$" "$file"; then
+ echo "ERROR: backend service is missing in $file." >&2
+ exit 1
+ fi
+done
+
+legacy_refs="$(
+ grep -R -nE "golang:1\\.25|^go 1\\.25" \
+ "$ROOT_DIR/backend" \
+ "$ROOT_DIR/docker-compose.yaml" \
+ "$ROOT_DIR/docker" \
+ "$ROOT_DIR/deploy/templates" \
+ "$README" \
+ "$README_EN" \
+ "$TEST_GUIDE" \
+ "$COMPLETION_REPORT" || true
+)"
+
+if [[ -n "$legacy_refs" ]]; then
+ echo "ERROR: legacy backend Go version references remain." >&2
+ echo "$legacy_refs" >&2
+ exit 1
+fi
+
+echo "OK: backend Go base version policy is ${TARGET_GO_VERSION}"
diff --git a/test/make_dev_targets_test.sh b/test/make_dev_targets_test.sh
index 92a02b75..4d9453f8 100644
--- a/test/make_dev_targets_test.sh
+++ b/test/make_dev_targets_test.sh
@@ -3,6 +3,19 @@ set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+dry_run_default_dev="$(
+ make --dry-run --always-make -C "$repo_root" dev 2>&1
+)"
+
+default_app_up_line="$(
+ grep -E "docker compose .* -f docker-compose.yaml up .*backend.*adminfront.*devfront.*orgfront.*userfront" <<<"$dry_run_default_dev" | tail -1
+)"
+
+if [[ -z "$default_app_up_line" ]]; then
+ echo "make dev must include orgfront in the default development app services." >&2
+ exit 1
+fi
+
dry_run_dev="$(
make --dry-run --always-make -C "$repo_root" dev DEV_SERVICES="backend adminfront" 2>&1
)"
@@ -17,6 +30,11 @@ if ! grep -q "Ensuring Ory stack" <<<"$dry_run_dev"; then
exit 1
fi
+if ! grep -q "Rendering Ory config" <<<"$dry_run_dev"; then
+ echo "make dev must render Ory config before starting services." >&2
+ exit 1
+fi
+
app_up_line="$(
grep -E "docker compose .* -f docker-compose.yaml up .*backend.*adminfront" <<<"$dry_run_dev" | tail -1
)"
@@ -31,6 +49,11 @@ if grep -q -- " -d" <<<"$app_up_line"; then
exit 1
fi
+if ! grep -q -- " --build" <<<"$app_up_line"; then
+ echo "make dev must rebuild app service images before starting development containers." >&2
+ exit 1
+fi
+
dry_run_up_dev="$(
make --dry-run --always-make -C "$repo_root" up-dev 2>&1
)"
@@ -45,10 +68,56 @@ if ! grep -q "Ensuring Ory stack" <<<"$dry_run_up_dev"; then
exit 1
fi
+dry_run_up_app="$(
+ make --dry-run --always-make -C "$repo_root" up-app 2>&1
+)"
+
+if ! grep -q "Starting App stack (backend/userfront/adminfront/devfront/orgfront)" <<<"$dry_run_up_app"; then
+ echo "make up-app must announce orgfront as part of the app stack." >&2
+ exit 1
+fi
+
+if ! grep -q "Rendering Ory config" <<<"$dry_run_up_app"; then
+ echo "make up-app must render Ory config before starting services." >&2
+ exit 1
+fi
+
+up_app_line="$(
+ grep -E "docker compose .* -f docker-compose.yaml up .*backend.*adminfront.*devfront.*orgfront.*userfront|docker compose .* -f docker-compose.yaml up " <<<"$dry_run_up_app" | tail -1
+)"
+
+if ! grep -q -- " --build" <<<"$up_app_line"; then
+ echo "make up-app must rebuild app service images before starting containers." >&2
+ exit 1
+fi
+
dry_run_up_all="$(
make --dry-run --always-make -C "$repo_root" up-all 2>&1
)"
+if ! dry_run_up="$(
+ make --dry-run --always-make -C "$repo_root" up 2>&1
+)"; then
+ echo "make up must be available as the default full-stack startup target." >&2
+ echo "$dry_run_up" >&2
+ exit 1
+fi
+
+if ! grep -q "Starting ALL stacks (infra + ory + app)" <<<"$dry_run_up"; then
+ echo "make up must delegate to the full-stack startup flow." >&2
+ exit 1
+fi
+
+if ! grep -q "config/.generated/auth-config.env" <<<"$dry_run_up"; then
+ echo "make up must use generated env from config/.generated." >&2
+ exit 1
+fi
+
+if ! grep -q "Rendering Ory config" <<<"$dry_run_up"; then
+ echo "make up must render Ory config before compose up." >&2
+ exit 1
+fi
+
if ! grep -q "Ensuring Docker networks" <<<"$dry_run_up_all"; then
echo "make up-all must ensure external Docker networks before compose up." >&2
exit 1
diff --git a/test/oathkeeper_access_log_e2e_test.sh b/test/oathkeeper_access_log_e2e_test.sh
index 14482b7c..c76768bd 100755
--- a/test/oathkeeper_access_log_e2e_test.sh
+++ b/test/oathkeeper_access_log_e2e_test.sh
@@ -58,3 +58,66 @@ if (( after_rows <= before_rows )); then
docker logs --tail 100 ory_vector >&2 || true
exit 1
fi
+
+before_auth_ts="$(docker exec ory_clickhouse clickhouse-client --user "${ORY_CLICKHOUSE_USER:-ory}" --password "${ORY_CLICKHOUSE_PASSWORD:-orypass}" --query "SELECT now64(3)")"
+auth_status="$(docker run --rm --network public_net curlimages/curl:8.10.1 \
+ -sS -o /dev/null -w '%{http_code}' \
+ 'http://ory_oathkeeper:4455/oauth2/auth?client_id=orgfront&redirect_uri=http%3A%2F%2Flocalhost%3A5175%2Fauth%2Fcallback&response_type=code&scope=openid&state=access-log-e2e&code_challenge=accessloge2e&code_challenge_method=S256')"
+
+if [[ "$auth_status" != "302" ]]; then
+ echo "ERROR: expected Oathkeeper OIDC auth request to return 302, got: $auth_status" >&2
+ exit 1
+fi
+
+deadline=$((SECONDS + 30))
+completed_rows=0
+granted_rows=0
+while (( SECONDS < deadline )); do
+ completed_rows="$(docker exec ory_clickhouse clickhouse-client --user "${ORY_CLICKHOUSE_USER:-ory}" --password "${ORY_CLICKHOUSE_PASSWORD:-orypass}" --query "
+ SELECT count()
+ FROM ory.oathkeeper_access_logs
+ WHERE timestamp >= toDateTime64('$before_auth_ts', 3)
+ AND method = 'GET'
+ AND path = '/oauth2/auth'
+ AND status = 302
+ ")"
+ granted_rows="$(docker exec ory_clickhouse clickhouse-client --user "${ORY_CLICKHOUSE_USER:-ory}" --password "${ORY_CLICKHOUSE_PASSWORD:-orypass}" --query "
+ SELECT count()
+ FROM ory.oathkeeper_access_logs
+ WHERE timestamp >= toDateTime64('$before_auth_ts', 3)
+ AND method = 'GET'
+ AND path = '/oauth2/auth'
+ AND client_id = 'orgfront'
+ AND decision = 'granted'
+ ")"
+ if (( completed_rows > 0 && granted_rows > 0 )); then
+ break
+ fi
+ sleep 2
+done
+
+if (( completed_rows <= 0 )); then
+ echo "ERROR: Oathkeeper completed request log did not preserve method/path/status." >&2
+ docker exec ory_clickhouse clickhouse-client --user "${ORY_CLICKHOUSE_USER:-ory}" --password "${ORY_CLICKHOUSE_PASSWORD:-orypass}" --query "
+ SELECT timestamp, method, path, status, client_id, decision
+ FROM ory.oathkeeper_access_logs
+ WHERE timestamp >= toDateTime64('$before_auth_ts', 3)
+ ORDER BY timestamp DESC
+ LIMIT 20
+ FORMAT Vertical
+ " >&2 || true
+ exit 1
+fi
+
+if (( granted_rows <= 0 )); then
+ echo "ERROR: Oathkeeper granted request log did not preserve client_id." >&2
+ docker exec ory_clickhouse clickhouse-client --user "${ORY_CLICKHOUSE_USER:-ory}" --password "${ORY_CLICKHOUSE_PASSWORD:-orypass}" --query "
+ SELECT timestamp, method, path, status, client_id, decision
+ FROM ory.oathkeeper_access_logs
+ WHERE timestamp >= toDateTime64('$before_auth_ts', 3)
+ ORDER BY timestamp DESC
+ LIMIT 20
+ FORMAT Vertical
+ " >&2 || true
+ exit 1
+fi
diff --git a/test/ory_log_pipeline_policy_test.sh b/test/ory_log_pipeline_policy_test.sh
index 18c2b5ed..01f30881 100755
--- a/test/ory_log_pipeline_policy_test.sh
+++ b/test/ory_log_pipeline_policy_test.sh
@@ -3,6 +3,8 @@ set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+"$repo_root/scripts/render_ory_config.sh" >/dev/null
+
docker run --rm \
-e ORY_CLICKHOUSE_USER=ory \
-e ORY_CLICKHOUSE_PASSWORD=orypass \
@@ -14,12 +16,12 @@ if grep -q '/etc/config/oathkeeper/rules.active.json' "$repo_root/docker/ory/oat
exit 1
fi
-if ! grep -q 'file:///tmp/oathkeeper/rules.active.json' "$repo_root/docker/ory/oathkeeper/oathkeeper.yml"; then
+if ! grep -q 'file:///tmp/oathkeeper/rules.active.json' "$repo_root/config/.generated/ory/oathkeeper/oathkeeper.yml"; then
echo "ERROR: Oathkeeper config must load active rules from writable runtime storage." >&2
exit 1
fi
-if ! grep -q '^version: v26.2.0$' "$repo_root/docker/ory/kratos/kratos.yml"; then
+if ! grep -q '^version: v26.2.0$' "$repo_root/config/.generated/ory/kratos/kratos.yml"; then
echo "ERROR: Kratos config version must match the v26.2.0 runtime." >&2
exit 1
fi
diff --git a/test/ory_v26_compose_policy_test.sh b/test/ory_v26_compose_policy_test.sh
index 5c1d3a29..ddf467ca 100644
--- a/test/ory_v26_compose_policy_test.sh
+++ b/test/ory_v26_compose_policy_test.sh
@@ -10,6 +10,26 @@ docker_config="$(
docker compose --env-file "$repo_root/.env" -f "$repo_root/docker/compose.ory.yaml" config
)"
+override_env="$(mktemp)"
+cp "$repo_root/.env" "$override_env"
+cat >> "$override_env" <<'EOF'
+USERFRONT_URL=https://compose-policy.example.test/sso
+HYDRA_PUBLIC_URL=https://compose-policy.example.test/sso/oidc
+KRATOS_UI_URL=https://compose-policy.example.test/ui
+KRATOS_BROWSER_URL=https://compose-policy.example.test/auth
+ADMINFRONT_CALLBACK_URLS=https://compose-policy.example.test/admin/callback
+DEVFRONT_CALLBACK_URLS=https://compose-policy.example.test/dev/callback
+ORGFRONT_CALLBACK_URLS=https://compose-policy.example.test/org/callback
+EOF
+trap 'rm -f "$override_env"' EXIT
+
+override_config="$(
+ docker compose --env-file "$override_env" -f "$repo_root/compose.ory.yaml" config
+)"
+override_docker_config="$(
+ docker compose --env-file "$override_env" -f "$repo_root/docker/compose.ory.yaml" config
+)"
+
for service in kratos hydra keto oathkeeper; do
version_key="$(tr '[:lower:]' '[:upper:]' <<<"$service")_VERSION"
expected_version="$(grep -E "^${version_key}=" "$repo_root/.env" | cut -d= -f2-)"
@@ -28,6 +48,64 @@ if grep -q "oryd/hydra:v25.4.0" <<<"$root_config"; then
exit 1
fi
+for compose_file in "$repo_root/compose.ory.yaml" "$repo_root/docker/compose.ory.yaml"; do
+ if grep -Eq 'redirect-uri .*:-.*https?://' "$compose_file"; then
+ echo "ERROR: $compose_file must not hard-code external redirect URI fallbacks; use .env variables." >&2
+ exit 1
+ fi
+ if grep -Eq 'KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=.*https?://localhost' "$compose_file"; then
+ echo "ERROR: $compose_file must not hard-code Kratos allowed return URL fallbacks; use .env variables." >&2
+ exit 1
+ fi
+ if awk 'in_block && /^ [A-Za-z0-9_-]+:/ { exit } /^ init-rp:/ { in_block=1 } in_block { print }' "$compose_file" | grep -q -- '--endpoint http://hydra:4445'; then
+ echo "ERROR: $compose_file init-rp must use HYDRA_ADMIN_URL instead of hard-coded Hydra admin endpoint." >&2
+ exit 1
+ fi
+ if awk 'in_block && /^ [A-Za-z0-9_-]+:/ { exit } /^[[:space:]]+oathkeeper:/ { in_block=1 } in_block { print }' "$compose_file" | grep -q "command: serve proxy --config /etc/config/oathkeeper/oathkeeper.yml"; then
+ echo "ERROR: $compose_file Oathkeeper must use entrypoint.sh instead of bypassing rules.active.json generation." >&2
+ exit 1
+ fi
+done
+
+for stack_check_file in \
+ "$repo_root/compose.ory.yaml" \
+ "$repo_root/docker/compose.ory.yaml" \
+ "$repo_root/docker/staging_pull_compose.template.yaml" \
+ "$repo_root/deploy/templates/docker-compose.yaml"
+do
+ if grep -q 'until curl -s http://' "$stack_check_file"; then
+ echo "ERROR: Ory stack check must not wait forever; use bounded readiness checks in $stack_check_file." >&2
+ exit 1
+ fi
+ if ! grep -q 'ORY_STACK_CHECK_MAX_ATTEMPTS' "$stack_check_file"; then
+ echo "ERROR: Ory stack check must expose ORY_STACK_CHECK_MAX_ATTEMPTS in $stack_check_file." >&2
+ exit 1
+ fi
+ if ! grep -q 'ERROR: Ory service not ready' "$stack_check_file"; then
+ echo "ERROR: Ory stack check must report the failed service name in $stack_check_file." >&2
+ exit 1
+ fi
+ if ! grep -q 'check_ready kratos .* || exit 1' "$stack_check_file"; then
+ echo "ERROR: Ory stack check must raise a non-zero exit when Kratos is not ready in $stack_check_file." >&2
+ exit 1
+ fi
+done
+
+for expected_url in \
+ "https://compose-policy.example.test/sso/oidc" \
+ "https://compose-policy.example.test/sso/login" \
+ "https://compose-policy.example.test/sso/consent" \
+ "https://compose-policy.example.test/sso/error" \
+ "https://compose-policy.example.test/admin/callback" \
+ "https://compose-policy.example.test/dev/callback" \
+ "https://compose-policy.example.test/org/callback"
+do
+ if ! grep -q "$expected_url" <<<"$override_config$override_docker_config"; then
+ echo "ERROR: Ory compose config must render env override URL: $expected_url" >&2
+ exit 1
+ fi
+done
+
root_init_rp="$(
awk 'in_block && /^ [A-Za-z0-9_-]+:/ { exit } /^ init-rp:/ { in_block=1 } in_block { print }' "$repo_root/compose.ory.yaml"
)"
@@ -53,3 +131,204 @@ if grep -q "releases/download/v25.4.0" "$repo_root/docker/staging_pull_compose.t
echo "ERROR: staging pull compose must not download a hard-coded Hydra v25.4.0 CLI." >&2
exit 1
fi
+
+staging_pull_template="$repo_root/docker/staging_pull_compose.template.yaml"
+
+if ! grep -q 'entrypoint: \["/etc/config/oathkeeper/entrypoint.sh"\]' "$staging_pull_template"; then
+ echo "ERROR: staging pull Oathkeeper must use the env-aware entrypoint." >&2
+ exit 1
+fi
+
+if grep -q "command: serve proxy --config /etc/config/oathkeeper/oathkeeper.yml" "$staging_pull_template"; then
+ echo "ERROR: staging pull Oathkeeper must not bypass entrypoint.sh with a direct command." >&2
+ exit 1
+fi
+
+if ! grep -q "URLS_SELF_ISSUER=\${HYDRA_PUBLIC_URL}" "$staging_pull_template"; then
+ echo "ERROR: staging pull Hydra issuer must use HYDRA_PUBLIC_URL." >&2
+ exit 1
+fi
+
+if grep -Eq '(KRATOS_(SERVE|SELFSERVICE|UI|BROWSER|PUBLIC|ADMIN).*:-http://localhost|URLS_.*:-http://localhost)' "$staging_pull_template"; then
+ echo "ERROR: staging pull Ory browser URLs must not fall back to localhost." >&2
+ exit 1
+fi
+
+if ! grep -q 'KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=${KRATOS_ALLOWED_RETURN_URLS_JSON' "$staging_pull_template"; then
+ echo "ERROR: staging pull Kratos allowed_return_urls must be driven by KRATOS_ALLOWED_RETURN_URLS_JSON." >&2
+ exit 1
+fi
+
+for return_path in '/ko' '/en' '/auth/callback' '/ko/auth/callback' '/en/auth/callback'; do
+ if ! grep -q "$return_path" "$staging_pull_template" "$repo_root/deploy/templates/.env.template" "$repo_root/.gitea/workflows/staging_code_pull.yml"; then
+ echo "ERROR: staging/prod allowed_return_urls must include locale/callback path: $return_path" >&2
+ exit 1
+ fi
+done
+
+if grep -Eq 'ORGFRONT_CALLBACK_URLS=.*(172\.16\.10\.176|baron-orgchart\.hmac\.kr|, https?://)' "$staging_pull_template" "$repo_root/.gitea/workflows/staging_code_pull.yml"; then
+ echo "ERROR: staging pull OrgFront callbacks must not keep private IP, legacy orgchart domain, or comma-space URI entries." >&2
+ exit 1
+fi
+
+if grep -q "rewrite \\^/oidc" "$repo_root/gateway/nginx.conf"; then
+ echo "ERROR: gateway must preserve the /oidc prefix and let Oathkeeper strip it." >&2
+ exit 1
+fi
+
+for rules_file in \
+ "$repo_root/docker/ory/oathkeeper/rules.json" \
+ "$repo_root/docker/ory/oathkeeper/rules.stage.json" \
+ "$repo_root/docker/ory/oathkeeper/rules.prod.json"
+do
+ for rule_id in hydra-well-known hydra-well-known-oidc hydra-oauth2 hydra-oauth2-oidc hydra-userinfo hydra-userinfo-oidc; do
+ if ! grep -q "\"id\": \"$rule_id\"" "$rules_file"; then
+ echo "ERROR: Oathkeeper rules must expose Hydra public route in $rules_file: $rule_id" >&2
+ exit 1
+ fi
+ done
+ for prefixed_rule in hydra-well-known-oidc hydra-oauth2-oidc hydra-userinfo-oidc; do
+ if ! awk -v id="\"id\": \"$prefixed_rule\"" '
+ $0 ~ id { in_rule = 1 }
+ in_rule && /strip_path/ && /\/oidc/ { found = 1 }
+ in_rule && /^ }[,]?$/ { in_rule = 0 }
+ END { exit found ? 0 : 1 }
+ ' "$rules_file"; then
+ echo "ERROR: prefixed Oathkeeper route must strip /oidc in $rules_file: $prefixed_rule" >&2
+ exit 1
+ fi
+ done
+done
+
+for wildcard_rules_file in \
+ "$repo_root/docker/ory/oathkeeper/rules.json" \
+ "$repo_root/docker/ory/oathkeeper/rules.stage.json"
+do
+ if grep -q "<\\.\\*>://<\\.\\*>/" "$wildcard_rules_file"; then
+ echo "ERROR: wildcard Oathkeeper host must not swallow path segments in $wildcard_rules_file." >&2
+ exit 1
+ fi
+done
+
+deploy_template="$repo_root/deploy/templates/docker-compose.yaml"
+deploy_env_template="$repo_root/deploy/templates/.env.template"
+deploy_gateway_template="$repo_root/deploy/templates/gateway/nginx.conf"
+deploy_kratos_template="$repo_root/deploy/templates/ory/kratos/kratos.yml.template"
+deploy_oathkeeper_rules_template="$repo_root/deploy/templates/ory/oathkeeper/rules.json"
+
+for required_template in \
+ "$repo_root/deploy/templates/orgfront/vite.config.ts" \
+ "$repo_root/deploy/templates/orgfront/auth.ts" \
+ "$repo_root/docker/ory/init-db/01_create_dbs.sh" \
+ "$repo_root/docker/ory/hydra/hydra.yml.template" \
+ "$repo_root/docker/ory/keto/keto.yml.template" \
+ "$repo_root/docker/ory/oathkeeper/entrypoint.sh" \
+ "$repo_root/docker/ory/oathkeeper/oathkeeper.yml.template"
+do
+ if [[ ! -f "$required_template" ]]; then
+ echo "ERROR: deploy instance generation requires missing source file: $required_template" >&2
+ exit 1
+ fi
+done
+
+if grep -Eq "oryd/(kratos|hydra|keto|oathkeeper):v25\\.4\\.0" "$deploy_template"; then
+ echo "ERROR: deploy template Ory stack must not hard-code v25.4.0 images." >&2
+ exit 1
+fi
+
+for prod_sensitive_file in \
+ "$repo_root/docker/ory/oathkeeper/rules.prod.json" \
+ "$repo_root/docker/ory/kratos/kratos.yml.template" \
+ "$repo_root/deploy/templates/ory/kratos/kratos.yml.template"
+do
+ if grep -q "app\\.brsw\\.kr" "$prod_sensitive_file"; then
+ echo "ERROR: Ory production-sensitive config must not hard-code app.brsw.kr: $prod_sensitive_file" >&2
+ exit 1
+ fi
+done
+
+for compose_file in "$repo_root/compose.ory.yaml" "$repo_root/docker/compose.ory.yaml" "$repo_root/docker/staging_pull_compose.template.yaml"; do
+ if grep -Eq './docker/ory/(kratos|hydra|keto|oathkeeper):/etc/config/' "$compose_file"; then
+ echo "ERROR: Ory compose must mount rendered config/.generated/ory config, not source templates: $compose_file" >&2
+ exit 1
+ fi
+done
+
+if grep -Eq '\./ory/(kratos|hydra|keto|oathkeeper):/etc/config/' "$deploy_template"; then
+ echo "ERROR: deploy template must mount rendered config/.generated/ory config, not source templates." >&2
+ exit 1
+fi
+
+if grep -q 'ory/generated' "$deploy_template" "$repo_root/deploy/create-instance.sh"; then
+ echo "ERROR: deploy template must use config/.generated/ory, not ory/generated." >&2
+ exit 1
+fi
+
+if ! grep -q '^render-ory-config:' "$repo_root/Makefile"; then
+ echo "ERROR: Makefile must render Ory config before starting Ory services." >&2
+ exit 1
+fi
+
+if ! grep -q 'scripts/render_ory_config.sh' "$repo_root/.gitea/workflows/staging_code_pull.yml"; then
+ echo "ERROR: staging code pull must render Ory config before docker compose up." >&2
+ exit 1
+fi
+
+"$repo_root/scripts/render_ory_config.sh" >/dev/null
+
+for generated_config in \
+ "$repo_root/config/.generated/ory/kratos/kratos.yml" \
+ "$repo_root/config/.generated/ory/hydra/hydra.yml" \
+ "$repo_root/config/.generated/ory/keto/keto.yml" \
+ "$repo_root/config/.generated/ory/oathkeeper/oathkeeper.yml"
+do
+ if [[ ! -f "$generated_config" ]]; then
+ echo "ERROR: Ory rendered config is missing: $generated_config" >&2
+ exit 1
+ fi
+ if grep -q '\${' "$generated_config"; then
+ echo "ERROR: Ory rendered config must not contain placeholders: $generated_config" >&2
+ exit 1
+ fi
+done
+
+for service in kratos-migrate kratos hydra-migrate hydra keto-migrate keto oathkeeper_logs_init oathkeeper; do
+ if ! grep -q "^ $service:" "$deploy_template"; then
+ echo "ERROR: deploy template Ory stack must include service: $service" >&2
+ exit 1
+ fi
+done
+
+for version_key in KRATOS_VERSION HYDRA_VERSION KETO_VERSION OATHKEEPER_VERSION; do
+ if ! grep -q "^$version_key=v26\\.2\\.0$" "$deploy_env_template"; then
+ echo "ERROR: deploy env template must define $version_key=v26.2.0." >&2
+ exit 1
+ fi
+done
+
+if ! grep -q 'entrypoint: \["/etc/config/oathkeeper/entrypoint.sh"\]' "$deploy_template"; then
+ echo "ERROR: deploy template Oathkeeper must use the env-aware entrypoint." >&2
+ exit 1
+fi
+
+if grep -q "rewrite \\^/oidc" "$deploy_gateway_template"; then
+ echo "ERROR: deploy template gateway must preserve the /oidc prefix." >&2
+ exit 1
+fi
+
+if ! grep -q '^version: v26.2.0$' "$deploy_kratos_template"; then
+ echo "ERROR: deploy Kratos template config version must match v26.2.0." >&2
+ exit 1
+fi
+
+for rule_id in hydra-well-known hydra-well-known-oidc hydra-oauth2 hydra-oauth2-oidc hydra-userinfo hydra-userinfo-oidc; do
+ if ! grep -q "\"id\": \"$rule_id\"" "$deploy_oathkeeper_rules_template"; then
+ echo "ERROR: deploy Oathkeeper rules must expose Hydra public route: $rule_id" >&2
+ exit 1
+ fi
+done
+
+if ! grep -q '"strip_path": "/oidc"' "$deploy_oathkeeper_rules_template"; then
+ echo "ERROR: deploy Oathkeeper prefixed routes must strip /oidc with strip_path." >&2
+ exit 1
+fi
diff --git a/userfront/assets/translations/en.toml b/userfront/assets/translations/en.toml
index 5b81e861..4393a094 100644
--- a/userfront/assets/translations/en.toml
+++ b/userfront/assets/translations/en.toml
@@ -15,6 +15,7 @@ saman = "Saman"
[domain.tenant_type]
company = "Company"
company_group = "Company Group"
+organization = "Organization"
personal = "Personal"
user_group = "User Group"
diff --git a/userfront/assets/translations/ko.toml b/userfront/assets/translations/ko.toml
index e33608f5..8a84d53f 100644
--- a/userfront/assets/translations/ko.toml
+++ b/userfront/assets/translations/ko.toml
@@ -15,6 +15,7 @@ saman = "삼안"
[domain.tenant_type]
company = "COMPANY (일반 기업)"
company_group = "COMPANY_GROUP (그룹사/지주사)"
+organization = "ORGANIZATION (정규 조직)"
personal = "PERSONAL (개인 워크스페이스)"
user_group = "USER_GROUP (내부 부서/팀)"
diff --git a/userfront/assets/translations/template.toml b/userfront/assets/translations/template.toml
index f48cb117..497bb8c6 100644
--- a/userfront/assets/translations/template.toml
+++ b/userfront/assets/translations/template.toml
@@ -15,6 +15,7 @@ saman = ""
[domain.tenant_type]
company = ""
company_group = ""
+organization = ""
personal = ""
user_group = ""
diff --git a/userfront/lib/core/constants/error_whitelist.dart b/userfront/lib/core/constants/error_whitelist.dart
index af4e8558..c7f90188 100644
--- a/userfront/lib/core/constants/error_whitelist.dart
+++ b/userfront/lib/core/constants/error_whitelist.dart
@@ -1,14 +1,16 @@
-const Map internalErrorWhitelistMessages = {
- 'settings_disabled': '현재 계정 설정 화면은 준비 중입니다.',
- 'invalid_session': '세션이 만료되었습니다. 다시 로그인해 주세요.',
- 'verification_required': '추가 인증이 필요합니다. 안내에 따라 진행해 주세요.',
- 'recovery_expired': '재설정 링크가 만료되었습니다. 다시 요청해 주세요.',
- 'recovery_invalid': '재설정 링크가 유효하지 않습니다.',
- 'rate_limited': '요청이 많습니다. 잠시 후 다시 시도해 주세요.',
- 'not_found': '요청한 페이지를 찾을 수 없습니다.',
- 'bad_request': '입력값을 확인해 주세요.',
- 'password_or_email_mismatch': '이메일 혹은 비밀번호가 일치하지 않습니다.',
- 'tenant_not_allowed': '허용되지 않은 테넌트입니다.',
+const Map internalErrorWhitelistMessageKeys = {
+ 'settings_disabled': 'msg.userfront.error.whitelist.settings_disabled',
+ 'invalid_session': 'msg.userfront.error.whitelist.invalid_session',
+ 'verification_required':
+ 'msg.userfront.error.whitelist.verification_required',
+ 'recovery_expired': 'msg.userfront.error.whitelist.recovery_expired',
+ 'recovery_invalid': 'msg.userfront.error.whitelist.recovery_invalid',
+ 'rate_limited': 'msg.userfront.error.whitelist.rate_limited',
+ 'not_found': 'msg.userfront.error.whitelist.not_found',
+ 'bad_request': 'msg.userfront.error.whitelist.bad_request',
+ 'password_or_email_mismatch':
+ 'msg.userfront.error.whitelist.password_or_email_mismatch',
+ 'tenant_not_allowed': 'msg.userfront.error.whitelist.tenant_not_allowed',
};
const Set oryBypassErrorCodes = {
diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart
index 651ddd23..8d455e11 100644
--- a/userfront/lib/core/services/auth_proxy_service.dart
+++ b/userfront/lib/core/services/auth_proxy_service.dart
@@ -65,7 +65,7 @@ class AuthProxyService {
} else {
throw _error(
'err.userfront.auth_proxy.password_policy_fetch',
- '비밀번호 정책을 불러오지 못했습니다.',
+ 'Failed to load the password policy.',
);
}
}
@@ -84,7 +84,7 @@ class AuthProxyService {
}
throw _error(
'err.userfront.auth_proxy.profile_load',
- '프로필을 불러오지 못했습니다: {{error}}',
+ 'Failed to load the profile: {{error}}',
detail: response.body,
);
} finally {
@@ -110,7 +110,7 @@ class AuthProxyService {
}
throw _error(
'err.userfront.auth_proxy.profile_load',
- '프로필을 불러오지 못했습니다: {{error}}',
+ 'Failed to load the profile: {{error}}',
detail: response.body,
);
} finally {
@@ -144,7 +144,7 @@ class AuthProxyService {
} else {
throw _error(
'err.userfront.auth_proxy.tenant_info_fetch',
- '테넌트 정보를 불러오지 못했습니다.',
+ 'Failed to load tenant information.',
);
}
}
@@ -180,7 +180,7 @@ class AuthProxyService {
} else {
throw _error(
'err.userfront.auth_proxy.login_init',
- '로그인 초기화에 실패했습니다: {{error}}',
+ 'Failed to initialize login: {{error}}',
detail: response.body,
);
}
@@ -205,7 +205,7 @@ class AuthProxyService {
}
throw _error(
'err.userfront.auth_proxy.login_poll',
- '로그인 상태 확인에 실패했습니다: {{error}}',
+ 'Failed to check login status: {{error}}',
detail: response.body,
);
}
@@ -227,7 +227,7 @@ class AuthProxyService {
} else {
throw _error(
'err.userfront.auth_proxy.verify_failed',
- '검증에 실패했습니다: {{error}}',
+ 'Verification failed: {{error}}',
detail: response.body,
);
}
@@ -261,7 +261,7 @@ class AuthProxyService {
} else {
throw _error(
'err.userfront.auth_proxy.verify_failed',
- '검증에 실패했습니다: {{error}}',
+ 'Verification failed: {{error}}',
detail: response.body,
);
}
@@ -281,7 +281,7 @@ class AuthProxyService {
if (response.statusCode != 200) {
throw _error(
'err.userfront.dashboard.sessions.revoke',
- '세션 종료에 실패했습니다: {{error}}',
+ 'Failed to revoke the session: {{error}}',
detail: response.body,
);
}
@@ -304,7 +304,7 @@ class AuthProxyService {
if (response.statusCode != 200) {
throw _error(
'err.userfront.dashboard.sessions.load',
- '활성 세션을 불러오지 못했습니다: {{error}}',
+ 'Failed to load the active sessions: {{error}}',
detail: response.body,
);
}
@@ -342,7 +342,7 @@ class AuthProxyService {
} else {
throw _error(
'err.userfront.auth_proxy.verify_failed',
- '검증에 실패했습니다: {{error}}',
+ 'Verification failed: {{error}}',
detail: response.body,
);
}
@@ -568,7 +568,7 @@ class AuthProxyService {
if (response.statusCode != 200) {
throw _error(
'err.userfront.auth_proxy.sms_send',
- 'SMS 전송에 실패했습니다: {{error}}',
+ 'Failed to send SMS: {{error}}',
detail: response.body,
);
}
@@ -591,7 +591,7 @@ class AuthProxyService {
} else {
throw _error(
'err.userfront.auth_proxy.code_verify',
- '인증 코드 확인에 실패했습니다: {{error}}',
+ 'Failed to verify the code: {{error}}',
detail: response.body,
);
}
@@ -609,7 +609,7 @@ class AuthProxyService {
} else {
throw _error(
'err.userfront.auth_proxy.qr_init',
- 'QR 로그인을 시작하지 못했습니다: {{error}}',
+ 'Failed to start QR login: {{error}}',
detail: response.body,
);
}
@@ -631,7 +631,7 @@ class AuthProxyService {
}
throw _error(
'err.userfront.auth_proxy.qr_poll',
- 'QR 상태 확인에 실패했습니다: {{error}}',
+ 'Failed to check QR status: {{error}}',
detail: response.body,
);
}
@@ -669,7 +669,7 @@ class AuthProxyService {
if (response.statusCode != 200) {
throw _error(
'err.userfront.auth_proxy.qr_approve',
- 'QR 승인에 실패했습니다: {{error}}',
+ 'Failed to approve QR login: {{error}}',
detail: response.body,
);
}
@@ -720,7 +720,7 @@ class AuthProxyService {
if (response.statusCode != 200) {
throw _error(
'err.userfront.auth_proxy.user_create',
- '사용자 생성에 실패했습니다: {{error}}',
+ 'Failed to create the user: {{error}}',
detail: response.body,
);
}
@@ -749,7 +749,7 @@ class AuthProxyService {
} else {
throw _error(
'err.userfront.auth_proxy.user_list',
- '사용자 목록 조회에 실패했습니다: {{error}}',
+ 'Failed to load the user list: {{error}}',
detail: response.body,
);
}
@@ -770,7 +770,7 @@ class AuthProxyService {
if (response.statusCode != 200) {
throw _error(
'err.userfront.auth_proxy.user_delete',
- '사용자 삭제에 실패했습니다: {{error}}',
+ 'Failed to delete the user: {{error}}',
detail: response.body,
);
}
@@ -796,7 +796,7 @@ class AuthProxyService {
if (response.statusCode != 200) {
throw _error(
'err.userfront.auth_proxy.user_status_update',
- '상태 업데이트에 실패했습니다: {{error}}',
+ 'Failed to update the user status: {{error}}',
detail: response.body,
);
}
@@ -829,7 +829,7 @@ class AuthProxyService {
if (response.statusCode != 200) {
throw _error(
'err.userfront.auth_proxy.user_update',
- '사용자 수정에 실패했습니다: {{error}}',
+ 'Failed to update the user: {{error}}',
detail: response.body,
);
}
@@ -855,7 +855,7 @@ class AuthProxyService {
} else {
throw _error(
'err.userfront.auth_proxy.linked_apps_load',
- '연동된 앱 목록을 불러오지 못했습니다.',
+ 'Failed to load linked applications.',
);
}
} finally {
@@ -1043,7 +1043,7 @@ class AuthProxyService {
if (response.statusCode != 200) {
throw _error(
'err.userfront.auth_proxy.phone_code_send',
- '인증 코드 전송에 실패했습니다: {{error}}',
+ 'Failed to send the verification code: {{error}}',
detail: response.body,
);
}
diff --git a/userfront/lib/features/auth/presentation/error_screen.dart b/userfront/lib/features/auth/presentation/error_screen.dart
index 016d3fe4..f1dc9d81 100644
--- a/userfront/lib/features/auth/presentation/error_screen.dart
+++ b/userfront/lib/features/auth/presentation/error_screen.dart
@@ -282,9 +282,9 @@ class _ErrorScreenState extends State {
final isProd = widget.isProdOverride ?? AuthProxyService.isProdEnv;
final normalizedCode = (widget.errorCode ?? '').trim();
final hasCode = normalizedCode.isNotEmpty;
- final internalWhitelistFallback =
- internalErrorWhitelistMessages[normalizedCode];
- final isInternalWhitelisted = internalWhitelistFallback != null;
+ final internalWhitelistKey =
+ internalErrorWhitelistMessageKeys[normalizedCode];
+ final isInternalWhitelisted = internalWhitelistKey != null;
final isOryBypass = hasCode && oryBypassErrorCodes.contains(normalizedCode);
final isKnownProdCode = hasCode && (isInternalWhitelisted || isOryBypass);
final isTenantAccessBlocked = normalizedCode == 'tenant_not_allowed';
@@ -294,7 +294,7 @@ class _ErrorScreenState extends State {
final title = isTenantAccessBlocked
? tr(
'msg.userfront.error.tenant.page_title',
- fallback: '애플리케이션 접근이 제한되었습니다',
+ fallback: 'Application access is restricted',
)
: isProd
? tr('msg.userfront.error.title')
@@ -332,17 +332,18 @@ class _ErrorScreenState extends State {
final showTenantLookupFallback =
_tenantAccessDetails == null &&
(emailLabel.isEmpty || tenantLabel.isEmpty);
+ final internalWhitelistDetail = internalWhitelistKey == null
+ ? null
+ : tr(internalWhitelistKey);
final detail = isTenantAccessBlocked
? tr(
'msg.userfront.error.tenant.detail',
- fallback: '현재 로그인된 계정은 이 애플리케이션에 접근할 수 없습니다.',
+ fallback:
+ 'The current signed-in account cannot access this application.',
)
: isProd
? (isInternalWhitelisted
- ? tr(
- 'msg.userfront.error.whitelist.$normalizedCode',
- fallback: internalWhitelistFallback,
- )
+ ? internalWhitelistDetail!
: (isOryBypass
? tr(
'msg.userfront.error.ory.$normalizedCode',
@@ -422,7 +423,7 @@ class _ErrorScreenState extends State {
Text(
tr(
'msg.userfront.error.tenant.title',
- fallback: '접근 제한 정보',
+ fallback: 'Access restriction details',
),
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
@@ -447,7 +448,8 @@ class _ErrorScreenState extends State {
child: Text(
tr(
'msg.userfront.error.tenant.loading',
- fallback: '현재 계정 정보를 불러오는 중입니다.',
+ fallback:
+ 'Loading the current account details.',
),
style: theme.textTheme.bodySmall
?.copyWith(
@@ -462,39 +464,39 @@ class _ErrorScreenState extends State {
_InfoRow(
label: tr(
'msg.userfront.error.tenant.account',
- fallback: '계정',
+ fallback: 'Account',
),
value: emailLabel.isNotEmpty
? emailLabel
: tr(
'msg.userfront.error.tenant.account_unknown',
- fallback: '알 수 없음',
+ fallback: 'Unknown',
),
),
const SizedBox(height: 8),
_InfoRow(
label: tr(
'msg.userfront.error.tenant.primary_tenant',
- fallback: '대표 소속 테넌트',
+ fallback: 'Primary affiliated tenant',
),
value: tenantLabel.isNotEmpty
? tenantLabel
: tr(
'msg.userfront.error.tenant.tenant_unknown',
- fallback: '알 수 없음',
+ fallback: 'Unknown',
),
),
const SizedBox(height: 8),
_InfoRow(
label: tr(
'msg.userfront.error.tenant.affiliated_tenants',
- fallback: '전체 소속 테넌트',
+ fallback: 'All affiliated tenants',
),
value: affiliatedTenantLabels.isNotEmpty
? affiliatedTenantLabels.join(', ')
: tr(
'msg.userfront.error.tenant.tenant_unknown',
- fallback: '알 수 없음',
+ fallback: 'Unknown',
),
),
if (showTenantLookupFallback) ...[
@@ -503,7 +505,7 @@ class _ErrorScreenState extends State {
tr(
'msg.userfront.error.tenant.lookup_fallback',
fallback:
- '표시 정보가 충분하지 않아 일부 항목은 확인되지 않을 수 있습니다.',
+ 'Some fields may be unavailable because there is not enough profile information to display.',
),
style: theme.textTheme.bodySmall
?.copyWith(
@@ -518,7 +520,7 @@ class _ErrorScreenState extends State {
tr(
'msg.userfront.error.tenant.load_failed',
fallback:
- '계정 정보를 확인하지 못했습니다. 다시 시도해 주세요.',
+ 'Failed to load account details. Please try again.',
),
style: theme.textTheme.bodySmall
?.copyWith(
@@ -548,7 +550,7 @@ class _ErrorScreenState extends State {
Text(
tr(
'msg.userfront.error.tenant.allowed_box_title',
- fallback: '접속 가능 테넌트',
+ fallback: 'Allowed tenants',
),
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
@@ -559,7 +561,7 @@ class _ErrorScreenState extends State {
_InfoRow(
label: tr(
'msg.userfront.error.tenant.allowed_tenants',
- fallback: '접속 가능 테넌트',
+ fallback: 'Allowed tenants',
),
value: allowedTenantLabels.join(', '),
),
@@ -567,11 +569,11 @@ class _ErrorScreenState extends State {
_InfoRow(
label: tr(
'msg.userfront.error.tenant.allowed_tenants',
- fallback: '접속 가능 테넌트',
+ fallback: 'Allowed tenants',
),
value: tr(
'msg.userfront.error.tenant.tenant_unknown',
- fallback: '알 수 없음',
+ fallback: 'Unknown',
),
),
],
diff --git a/userfront/lib/features/auth/presentation/signup_screen.dart b/userfront/lib/features/auth/presentation/signup_screen.dart
index 8cf6946c..66c607ef 100644
--- a/userfront/lib/features/auth/presentation/signup_screen.dart
+++ b/userfront/lib/features/auth/presentation/signup_screen.dart
@@ -838,11 +838,18 @@ class _SignupScreenState extends State {
static String _resolveAgreementText(
String key, {
required String fallback,
+ String? englishFallback,
required Set placeholders,
}) {
final localized = tr(key, fallback: '').trim();
- if (localized.isEmpty || placeholders.contains(localized)) {
- return fallback;
+ final hasCorruptedEscapes = RegExp(r'\\{3,}').hasMatch(localized);
+ final preferredLocaleCode = resolvePreferredLocaleCode();
+ final useEnglishFallback =
+ preferredLocaleCode.startsWith('en') && englishFallback != null;
+ if (localized.isEmpty ||
+ placeholders.contains(localized) ||
+ hasCorruptedEscapes) {
+ return useEnglishFallback ? englishFallback : fallback;
}
return localized;
}
@@ -918,10 +925,106 @@ class _SignupScreenState extends State {
본 약관에 따른 분쟁은 서울중앙지방법원을 관할 법원으로 합니다.
부칙
본 약관은 2024년 10월 1일부터 시행됩니다.
+""";
+ const englishFallback = """
+Baron Software Terms of Service
+
+Chapter 1. General Provisions
+Article 1 (Purpose)
+These Terms of Service define the rights, obligations, responsibilities, and other necessary matters between Baron Consultant Co., Ltd. (the "Company") and users in connection with the use of Baron Software and related services (the "Service").
+
+Article 2 (Definitions)
+1. "Service" means the software and related services provided by the Company.
+2. "User" means any member or non-member who accesses and uses the Service.
+3. "Member" means a person who agrees to these Terms and enters into a service agreement with the Company.
+4. "Non-member" means a person who uses part of the Service without registering as a member.
+
+Article 3 (Effect and Amendment of the Terms)
+These Terms take effect when the User agrees to them and the Company accepts the registration. The Company may amend these Terms when necessary, and amended Terms become effective after notice is provided through the Service.
+
+Article 4 (Governing Rules)
+Matters not expressly provided in these Terms shall be governed by applicable laws of the Republic of Korea and general commercial practice.
+
+Chapter 2. Service Agreement
+Article 5 (Formation of the Agreement)
+The service agreement is formed when the User agrees to these Terms, submits the registration form provided by the Company, and the Company approves the registration.
+
+Article 6 (Reservation or Refusal of Registration)
+The Company may reserve or refuse registration if the application contains false information or if it is technically difficult to provide the Service.
+
+Article 7 (Changes to User Information)
+Members may review and edit their information at any time through the account management menu. Members must promptly update changed information and are responsible for problems arising from failure to do so.
+
+Chapter 3. Privacy Protection
+Article 8 (Principles of Privacy Protection)
+The Company protects Members' personal information in accordance with applicable laws. Detailed privacy matters are governed by the separate Privacy Policy.
+
+Article 9 (Compliance with the Privacy Policy)
+The collection, use, disclosure, retention, and protection of personal information are governed by the Privacy Policy, which Users may review at any time.
+
+Article 10 (Children Under 14)
+If the Company collects personal information from a child under the age of 14, the consent of a legal guardian is required.
+
+Chapter 4. Use of the Service
+Article 11 (Provision of the Service)
+The Company begins providing the Service once a registration request is approved. In principle, the Service is available 24 hours a day, 7 days a week.
+
+Article 12 (Change or Suspension of the Service)
+The Company may change or suspend the Service after prior notice when provision of the Service becomes difficult.
+
+Chapter 5. Information and Advertising
+Article 13 (Information and Advertising)
+The Company may provide information and advertising considered necessary during use of the Service. Members may opt out of unwanted communications where permitted.
+
+Chapter 6. User Content
+Article 14 (Management of Content)
+The Company may delete content posted by a Member if it is illegal or violates these Terms.
+
+Article 15 (Copyright)
+Copyright in content posted by Members belongs to the Member, but the Company may use such content for service promotion and improvement where permitted by law.
+
+Chapter 7. Termination and Restrictions
+Article 16 (Termination)
+Members may request termination of the agreement at any time, and the Company will process the request promptly.
+
+Article 17 (Restriction of Use)
+The Company may restrict access to the Service if a Member violates these Terms.
+
+Chapter 8. Damages and Disclaimer
+Article 18 (Damages)
+The Company is not liable for damages arising from free services unless required by law.
+
+Article 19 (Disclaimer)
+The Company is not liable where the Service cannot be provided due to force majeure such as natural disasters.
+
+Chapter 9. Paid Services
+Article 20 (Use of Paid Services)
+The Company may provide certain services for a fee. Pricing, payment methods, and refund procedures will be described on the service information page and payment screen. Fees are generally prepaid.
+
+Article 21 (Refund Policy)
+Users may receive a full refund if they do not start using a paid service within 7 days after payment. Partial refunds may apply when service suspension occurs for reasons not attributable to the User.
+
+Article 22 (Suspension and Cancellation of Paid Services)
+Members who wish to cancel a paid service must submit a cancellation request through customer support. The Company may immediately suspend and terminate paid services if the Member violates these Terms or uses the service improperly.
+
+Chapter 10. No Assignment
+Article 23 (No Assignment)
+Members may not assign, transfer, donate, or pledge their right to use the Service or their contractual status to a third party.
+
+Chapter 11. Governing Court
+Article 24 (Dispute Resolution)
+If a dispute arises in connection with the use of the Service, the Company and the Member shall make good-faith efforts to resolve it.
+
+Article 25 (Jurisdiction)
+Any dispute arising under these Terms shall be subject to the exclusive jurisdiction of the Seoul Central District Court.
+
+Supplementary Provision
+These Terms take effect on October 1, 2024.
""";
return _resolveAgreementText(
'msg.userfront.signup.tos_full',
fallback: fallback,
+ englishFallback: englishFallback,
placeholders: {'서비스 이용약관 전문...', 'Tos Full'},
);
}
@@ -1035,10 +1138,83 @@ class _SignupScreenState extends State {
회사는 이용자의 개인정보를 국외로 이전하지 않으며, 향후 필요한 경우, 사전에 이용자의 동의를 받습니다.
제8조 (기타)
본 방침에 명시되지 않은 사항은 회사의 내부 방침과 관련 법령에 따릅니다.
+""";
+ const englishFallback = """
+Consent to Collection and Use of Personal Information
+
+Baron Service Privacy Policy
+
+Article 1 (Purpose)
+Baron Consultant Co., Ltd. (the "Company") establishes this Privacy Policy to protect the personal information of customers and users of Baron Service (the "Service") and to fulfill its duties under the Personal Information Protection Act and other applicable laws.
+
+Article 2 (Purposes of Processing Personal Information)
+The Company processes personal information for the following purposes:
+- identity verification for registration and account management
+- communication by phone or email
+- provision of notices and operation of the Service
+- delivery of product materials
+- consultation and demo requests
+- event participation and seminar guidance
+- delivery of security guidance materials
+- technical support
+- service improvement feedback
+- marketing communications for users who have separately agreed
+
+Article 3 (Retention Period)
+The Company retains and uses personal information within the period required by law or agreed to by the data subject.
+- member information: from registration until 1 year after account deletion
+- promotional, consultation, and contract-related information: 2 years
+
+Article 4 (Provision to Third Parties)
+The Company processes personal information only within the scope described in this Policy and provides it to third parties only where consent has been obtained or where required by law.
+
+Article 5 (Entrustment of Processing)
+The Company does not currently entrust personal information processing to external processors for the core scope described here. If outsourcing becomes necessary, the Company will provide notice and obtain consent where required.
+
+Article 6 (Rights of Data Subjects)
+Data subjects may request access, correction, deletion, suspension of processing, and other rights permitted by law. Requests may be submitted in writing, by email, or by facsimile. The Company may verify the identity or authority of the requester.
+
+Article 7 (Items of Personal Information Processed)
+The Company may process the following items:
+- required: name, mobile phone number, email address
+- optional: company telephone number, inquiry details
+- collection channels: website, phone, email
+
+Article 8 (Destruction of Personal Information)
+When personal information is no longer needed due to expiration of the retention period or achievement of the processing purpose, the Company destroys it without delay. Electronic records are deleted using technically appropriate methods, and paper documents are shredded or incinerated.
+
+Article 9 (Security Measures)
+The Company implements administrative, technical, and physical safeguards, including internal management plans, employee training, access control, encryption where appropriate, security software, and restricted access to facilities.
+
+Article 10 (Automatic Collection Devices)
+The Company does not use cookies for this Service in the scope described here.
+
+Article 11 (Chief Privacy Officer)
+The Company designates a privacy officer responsible for overall personal information protection and complaint handling.
+
+Article 12 (Requests for Access)
+Data subjects may submit requests for access to personal information to the department designated by the Company, and the Company will make reasonable efforts to respond promptly.
+
+Article 13 (Remedies for Rights Infringement)
+Data subjects may seek dispute resolution or consultation from competent authorities and institutions handling personal information disputes and complaints.
+
+Article 14 (Changes to This Privacy Policy)
+If this Policy is added to, deleted from, or otherwise modified due to changes in law, policy, or security technology, the Company will provide advance notice before the effective date.
+
+Supplementary Provisions
+1. Effective Date
+This Privacy Policy takes effect on October 1, 2024.
+2. Notice of Amendments
+The Company will notify users of amendments through service notices, the website, or email as appropriate.
+3. Severability
+If any part of this Policy is held invalid or unenforceable, the remaining provisions will remain effective.
+4. Miscellaneous
+Matters not expressly provided in this Policy are governed by the Company's internal policies and applicable laws.
""";
return _resolveAgreementText(
'msg.userfront.signup.privacy_full',
fallback: fallback,
+ englishFallback: englishFallback,
placeholders: {'개인정보 수집 및 이용 동의 전문...', 'Privacy Full'},
);
}
diff --git a/userfront/lib/i18n_data.dart b/userfront/lib/i18n_data.dart
index 5d1b4358..816137ed 100644
--- a/userfront/lib/i18n_data.dart
+++ b/userfront/lib/i18n_data.dart
@@ -34,9 +34,27 @@ const Map koStrings = {
"err.userfront.auth_proxy.consent_reject": "동의 거부에 실패했습니다.",
"err.userfront.auth_proxy.linked_app_revoke": "연동 해지에 실패했습니다.",
"err.userfront.auth_proxy.login_failed": "로그인에 실패했습니다.",
+ "err.userfront.auth_proxy.login_init": "로그인 초기화에 실패했습니다: {{error}}",
+ "err.userfront.auth_proxy.login_poll": "로그인 상태 확인에 실패했습니다: {{error}}",
"err.userfront.auth_proxy.oidc_accept": "OIDC 로그인 승인에 실패했습니다.",
"err.userfront.auth_proxy.password_reset_complete": "비밀번호 재설정에 실패했습니다.",
+ "err.userfront.auth_proxy.password_policy_fetch": "비밀번호 정책을 불러오지 못했습니다.",
"err.userfront.auth_proxy.password_reset_init": "비밀번호 재설정을 시작하지 못했습니다.",
+ "err.userfront.auth_proxy.profile_load": "프로필을 불러오지 못했습니다: {{error}}",
+ "err.userfront.auth_proxy.tenant_info_fetch": "테넌트 정보를 불러오지 못했습니다.",
+ "err.userfront.auth_proxy.verify_failed": "검증에 실패했습니다: {{error}}",
+ "err.userfront.auth_proxy.sms_send": "SMS 전송에 실패했습니다: {{error}}",
+ "err.userfront.auth_proxy.code_verify": "인증 코드 확인에 실패했습니다: {{error}}",
+ "err.userfront.auth_proxy.qr_init": "QR 로그인을 시작하지 못했습니다: {{error}}",
+ "err.userfront.auth_proxy.qr_poll": "QR 상태 확인에 실패했습니다: {{error}}",
+ "err.userfront.auth_proxy.qr_approve": "QR 승인에 실패했습니다: {{error}}",
+ "err.userfront.auth_proxy.user_create": "사용자 생성에 실패했습니다: {{error}}",
+ "err.userfront.auth_proxy.user_list": "사용자 목록 조회에 실패했습니다: {{error}}",
+ "err.userfront.auth_proxy.user_delete": "사용자 삭제에 실패했습니다: {{error}}",
+ "err.userfront.auth_proxy.user_status_update": "상태 업데이트에 실패했습니다: {{error}}",
+ "err.userfront.auth_proxy.user_update": "사용자 수정에 실패했습니다: {{error}}",
+ "err.userfront.auth_proxy.linked_apps_load": "연동된 앱 목록을 불러오지 못했습니다.",
+ "err.userfront.auth_proxy.phone_code_send": "인증 코드 전송에 실패했습니다: {{error}}",
"err.userfront.profile.load_failed": "프로필을 불러오지 못했습니다: {{error}}",
"err.userfront.profile.password_change_failed": "비밀번호 변경에 실패했습니다: {{error}}",
"err.userfront.profile.send_code_failed": "인증번호 전송 실패: {{error}}",
@@ -589,6 +607,7 @@ const Map koStrings = {
"재설정 링크가 만료되었습니다. 다시 요청해 주세요.",
"msg.userfront.error.whitelist.recovery_invalid": "재설정 링크가 유효하지 않습니다.",
"msg.userfront.error.whitelist.settings_disabled": "현재 계정 설정 화면은 준비 중입니다.",
+ "msg.userfront.error.whitelist.tenant_not_allowed": "허용되지 않은 테넌트입니다.",
"msg.userfront.error.whitelist.verification_required":
"추가 인증이 필요합니다. 안내에 따라 진행해 주세요.",
"msg.userfront.forgot.description":
@@ -2010,11 +2029,43 @@ const Map enStrings = {
"err.userfront.auth_proxy.linked_app_revoke":
"Failed to revoke the linked application.",
"err.userfront.auth_proxy.login_failed": "Login failed.",
+ "err.userfront.auth_proxy.login_init":
+ "Failed to initialize login: {{error}}",
+ "err.userfront.auth_proxy.login_poll":
+ "Failed to check login status: {{error}}",
"err.userfront.auth_proxy.oidc_accept": "OIDC Accept",
"err.userfront.auth_proxy.password_reset_complete":
"Failed to complete the password reset.",
+ "err.userfront.auth_proxy.password_policy_fetch":
+ "Failed to load the password policy.",
"err.userfront.auth_proxy.password_reset_init":
"Failed to start the password reset.",
+ "err.userfront.auth_proxy.profile_load":
+ "Failed to load the profile: {{error}}",
+ "err.userfront.auth_proxy.tenant_info_fetch":
+ "Failed to load tenant information.",
+ "err.userfront.auth_proxy.verify_failed": "Verification failed: {{error}}",
+ "err.userfront.auth_proxy.sms_send": "Failed to send SMS: {{error}}",
+ "err.userfront.auth_proxy.code_verify":
+ "Failed to verify the code: {{error}}",
+ "err.userfront.auth_proxy.qr_init": "Failed to start QR login: {{error}}",
+ "err.userfront.auth_proxy.qr_poll": "Failed to check QR status: {{error}}",
+ "err.userfront.auth_proxy.qr_approve":
+ "Failed to approve QR login: {{error}}",
+ "err.userfront.auth_proxy.user_create":
+ "Failed to create the user: {{error}}",
+ "err.userfront.auth_proxy.user_list":
+ "Failed to load the user list: {{error}}",
+ "err.userfront.auth_proxy.user_delete":
+ "Failed to delete the user: {{error}}",
+ "err.userfront.auth_proxy.user_status_update":
+ "Failed to update the user status: {{error}}",
+ "err.userfront.auth_proxy.user_update":
+ "Failed to update the user: {{error}}",
+ "err.userfront.auth_proxy.linked_apps_load":
+ "Failed to load linked applications.",
+ "err.userfront.auth_proxy.phone_code_send":
+ "Failed to send the verification code: {{error}}",
"err.userfront.profile.load_failed": "Failed to load the profile.",
"err.userfront.profile.password_change_failed": "Password Change Failed",
"err.userfront.profile.send_code_failed":
@@ -2669,6 +2720,8 @@ const Map enStrings = {
"The recovery link is invalid.",
"msg.userfront.error.whitelist.settings_disabled":
"Account settings are currently unavailable.",
+ "msg.userfront.error.whitelist.tenant_not_allowed":
+ "This tenant is not allowed.",
"msg.userfront.error.whitelist.verification_required":
"Additional verification is required. Please follow the instructions.",
"msg.userfront.forgot.description":
diff --git a/userfront/test/error_screen_test.dart b/userfront/test/error_screen_test.dart
index 6c38c57d..da3454c8 100644
--- a/userfront/test/error_screen_test.dart
+++ b/userfront/test/error_screen_test.dart
@@ -78,7 +78,7 @@ void main() {
);
final detail = tr(
'msg.userfront.error.whitelist.settings_disabled',
- fallback: internalErrorWhitelistMessages['settings_disabled']!,
+ fallback: tr(internalErrorWhitelistMessageKeys['settings_disabled']!),
);
final type = tr(
'msg.userfront.error.type',
@@ -160,7 +160,7 @@ void main() {
final detail = tr(
'msg.userfront.error.whitelist.not_found',
- fallback: internalErrorWhitelistMessages['not_found']!,
+ fallback: tr(internalErrorWhitelistMessageKeys['not_found']!),
);
final type = tr(
'msg.userfront.error.type',
@@ -185,7 +185,7 @@ void main() {
final detail = tr(
'msg.userfront.error.whitelist.rate_limited',
- fallback: internalErrorWhitelistMessages['rate_limited']!,
+ fallback: tr(internalErrorWhitelistMessageKeys['rate_limited']!),
);
final type = tr(
'msg.userfront.error.type',
@@ -214,28 +214,12 @@ void main() {
},
);
- final title = tr(
- 'msg.userfront.error.tenant.page_title',
- fallback: '애플리케이션 접근이 제한되었습니다',
- );
- final detail = tr(
- 'msg.userfront.error.tenant.detail',
- fallback: '현재 로그인된 계정은 이 애플리케이션에 접근할 수 없습니다.',
- );
- final account = tr('msg.userfront.error.tenant.account', fallback: '계정');
- final primaryTenant = tr(
- 'msg.userfront.error.tenant.primary_tenant',
- fallback: '대표 소속 테넌트',
- );
- final affiliatedTenants = tr(
- 'msg.userfront.error.tenant.affiliated_tenants',
- fallback: '전체 소속 테넌트',
- );
- final switchAccount = tr(
- 'ui.userfront.error.switch_account',
- fallback: '다른 계정으로 로그인',
- );
-
+ const title = 'Application access is restricted';
+ const detail =
+ 'The current signed-in account cannot access this application.';
+ const account = 'Account';
+ const primaryTenant = 'Primary affiliated tenant';
+ const affiliatedTenants = 'All affiliated tenants';
expect(find.text(title), findsOneWidget);
expect(find.text(detail), findsOneWidget);
expect(find.text(account), findsOneWidget);
@@ -243,7 +227,8 @@ void main() {
expect(find.text(primaryTenant), findsOneWidget);
expect(find.text(affiliatedTenants), findsOneWidget);
expect(find.text('Baron HQ'), findsNWidgets(2));
- expect(find.text(switchAccount), findsOneWidget);
+ expect(find.byType(ElevatedButton), findsOneWidget);
+ expect(find.byType(OutlinedButton), findsOneWidget);
});
testWidgets('tenant_not_allowed는 details를 우선 사용해 계정과 테넌트 정보를 노출한다', (