Skip to content

Commit 08747da

Browse files
authored
feat: brand the internal zitadel with FUTO styling (#1721)
1 parent fb82133 commit 08747da

6 files changed

Lines changed: 207 additions & 1 deletion

File tree

Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// FUTO brand palette, sourced from futo.tech design tokens:
2+
// primary = accent-blue-400 (#3c9eea)
3+
// warn = accent-yellow-400 (#f2b859)
4+
// bg dark = neutral-blue-950 (#02070a)
5+
// font dk = neutral-blue-200 (#cfeaff)
6+
//
7+
// This is FUTO's *internal* instance, so it is distinguished from the
8+
// customer-facing instances two ways: the primary accent is the FUTO amber
9+
// (#f2b859) rather than blue (which carries into the console, not just login),
10+
// and the logo carries an amber "INTERNAL" badge so the login screen says so
11+
// explicitly (common.title only sets the browser tab title in the v2 UI).
12+
resource "zitadel_default_label_policy" "default" {
13+
primary_color = "#f2b859"
14+
warn_color = "#f2b859"
15+
background_color = "#ffffff"
16+
font_color = "#02070a"
17+
18+
primary_color_dark = "#f2b859"
19+
warn_color_dark = "#f2b859"
20+
background_color_dark = "#02070a"
21+
font_color_dark = "#cfeaff"
22+
23+
hide_login_name_suffix = true
24+
disable_watermark = true
25+
theme_mode = "THEME_MODE_AUTO"
26+
27+
logo_path = "${path.module}/assets/logo.svg"
28+
logo_hash = filemd5("${path.module}/assets/logo.svg")
29+
30+
logo_dark_path = "${path.module}/assets/logo.svg"
31+
logo_dark_hash = filemd5("${path.module}/assets/logo.svg")
32+
33+
icon_path = "${path.module}/assets/icon.svg"
34+
icon_hash = filemd5("${path.module}/assets/icon.svg")
35+
36+
icon_dark_path = "${path.module}/assets/icon.svg"
37+
icon_dark_hash = filemd5("${path.module}/assets/icon.svg")
38+
39+
set_active = true
40+
}
41+
42+
resource "zitadel_default_privacy_policy" "default" {
43+
tos_link = "https://futo.tech/terms"
44+
privacy_link = "https://futo.tech/privacy"
45+
help_link = "https://futo.tech/help"
46+
support_email = "support@futo.tech"
47+
}
48+
49+
// The ZITADEL terraform provider does not expose SettingsService.
50+
// SetHostedLoginTranslation (used by the v2 hosted login UI), so the "FUTO
51+
// Internal" login-title override is pushed via the same local-exec script the
52+
// customer module uses, keyed on a content hash so it only re-runs on change.
53+
locals {
54+
hosted_login_translations_file = "${path.module}/translations/en.json"
55+
hosted_login_translations_script = "${path.module}/scripts/set-hosted-login-translations.sh"
56+
hosted_login_translations_domain = "auth.internal.futo.org"
57+
hosted_login_translations_locale = "en"
58+
}
59+
60+
resource "terraform_data" "hosted_login_translations" {
61+
triggers_replace = [
62+
filemd5(local.hosted_login_translations_file),
63+
filemd5(local.hosted_login_translations_script),
64+
local.hosted_login_translations_domain,
65+
local.hosted_login_translations_locale,
66+
]
67+
68+
provisioner "local-exec" {
69+
command = local.hosted_login_translations_script
70+
71+
environment = {
72+
ZITADEL_DOMAIN = local.hosted_login_translations_domain
73+
ZITADEL_PROFILE_JSON = var.futo_zitadel_profile_json
74+
TRANSLATIONS_FILE = local.hosted_login_translations_file
75+
LOCALE = local.hosted_login_translations_locale
76+
}
77+
}
78+
79+
depends_on = [zitadel_default_label_policy.default]
80+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
#!/usr/bin/env bash
2+
# Push hosted-login-UI (v2) custom translations to a ZITADEL instance.
3+
#
4+
# Required env vars:
5+
# ZITADEL_DOMAIN instance domain, no scheme (e.g. auth.futo.org)
6+
# ZITADEL_PROFILE_JSON JWT profile JSON for a machine user with
7+
# iam.policy.write (i.e. IAM_OWNER)
8+
# TRANSLATIONS_FILE path to a JSON file with the override keys
9+
# (deep-merged over bundled defaults by the v2 UI)
10+
# LOCALE BCP 47 language tag (e.g. "en")
11+
#
12+
# Requires: curl, jq, openssl (all standard on a dev box).
13+
14+
set -euo pipefail
15+
16+
: "${ZITADEL_DOMAIN:?ZITADEL_DOMAIN is required}"
17+
: "${ZITADEL_PROFILE_JSON:?ZITADEL_PROFILE_JSON is required}"
18+
: "${TRANSLATIONS_FILE:?TRANSLATIONS_FILE is required}"
19+
: "${LOCALE:?LOCALE is required}"
20+
21+
# Tolerate values that include a scheme and/or trailing slash.
22+
ZITADEL_DOMAIN="${ZITADEL_DOMAIN#https://}"
23+
ZITADEL_DOMAIN="${ZITADEL_DOMAIN#http://}"
24+
ZITADEL_DOMAIN="${ZITADEL_DOMAIN%/}"
25+
26+
if [[ ! -f "$TRANSLATIONS_FILE" ]]; then
27+
echo "translations file not found: $TRANSLATIONS_FILE" >&2
28+
exit 1
29+
fi
30+
31+
# -- base64url helpers ------------------------------------------------------
32+
b64url() {
33+
# Stream in -> base64url out (no padding). `tr -d '\n'` instead of GNU `base64 -w0`
34+
# so this works on macOS/BSD base64 too.
35+
base64 | tr -d '\n' | tr '+/' '-_' | tr -d '='
36+
}
37+
38+
# -- parse profile JSON -----------------------------------------------------
39+
profile_tmp=""; key_tmp=""; signing_tmp=""; sig_tmp=""
40+
trap 'rm -f "$profile_tmp" "$key_tmp" "$signing_tmp" "$sig_tmp"' EXIT
41+
profile_tmp=$(mktemp)
42+
echo "$ZITADEL_PROFILE_JSON" >"$profile_tmp"
43+
44+
key_id=$(jq -r '.keyId' "$profile_tmp")
45+
user_id=$(jq -r '.userId' "$profile_tmp")
46+
if [[ -z "$key_id" || "$key_id" == "null" || -z "$user_id" || "$user_id" == "null" ]]; then
47+
echo "ZITADEL_PROFILE_JSON is missing keyId or userId" >&2
48+
exit 1
49+
fi
50+
51+
key_tmp=$(mktemp)
52+
jq -r '.key' "$profile_tmp" >"$key_tmp"
53+
54+
# -- build + sign JWT assertion --------------------------------------------
55+
now=$(date +%s)
56+
exp=$((now + 60))
57+
issuer="https://${ZITADEL_DOMAIN}"
58+
59+
header=$(printf '{"alg":"RS256","typ":"JWT","kid":"%s"}' "$key_id" | b64url)
60+
payload=$(jq -cn \
61+
--arg iss "$user_id" \
62+
--arg sub "$user_id" \
63+
--arg aud "$issuer" \
64+
--argjson iat "$now" \
65+
--argjson exp "$exp" \
66+
'{iss:$iss, sub:$sub, aud:$aud, iat:$iat, exp:$exp}' | b64url)
67+
68+
signing_tmp=$(mktemp)
69+
printf '%s.%s' "$header" "$payload" >"$signing_tmp"
70+
71+
sig_tmp=$(mktemp)
72+
openssl dgst -sha256 -sign "$key_tmp" -out "$sig_tmp" "$signing_tmp"
73+
signature=$(b64url <"$sig_tmp")
74+
75+
assertion="${header}.${payload}.${signature}"
76+
77+
# -- exchange for access token ---------------------------------------------
78+
token_response=$(curl -sS -X POST "${issuer}/oauth/v2/token" \
79+
-H "Content-Type: application/x-www-form-urlencoded" \
80+
--data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \
81+
--data-urlencode "scope=openid urn:zitadel:iam:org:project:id:zitadel:aud" \
82+
--data-urlencode "assertion=${assertion}")
83+
84+
access_token=$(jq -r '.access_token // empty' <<<"$token_response")
85+
if [[ -z "$access_token" ]]; then
86+
echo "token exchange failed:" >&2
87+
echo "$token_response" >&2
88+
exit 1
89+
fi
90+
91+
# -- PUT translations -------------------------------------------------------
92+
body=$(jq -c --arg locale "$LOCALE" \
93+
'{instance: true, locale: $locale, translations: .}' \
94+
"$TRANSLATIONS_FILE")
95+
96+
response=$(curl -sS -w '\n%{http_code}' -X PUT \
97+
"${issuer}/v2/settings/hosted_login_translation" \
98+
-H "Authorization: Bearer ${access_token}" \
99+
-H "Content-Type: application/json" \
100+
--data "$body")
101+
102+
status=$(tail -n1 <<<"$response")
103+
body_out=$(sed '$d' <<<"$response")
104+
105+
if [[ "$status" -lt 200 || "$status" -ge 300 ]]; then
106+
echo "PUT /v2/settings/hosted_login_translation failed (HTTP $status):" >&2
107+
echo "$body_out" >&2
108+
exit 1
109+
fi
110+
111+
echo "hosted login translations updated for locale=$LOCALE (HTTP $status)"

tf/deployment/modules/shared/zitadel/cloud/terragrunt.hcl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ terraform {
55
commands = get_terraform_commands_that_need_vars()
66
}
77

8-
include_in_copy = ["repo-files/*", "scripts/*"]
8+
include_in_copy = ["repo-files/*", "scripts/*", "assets/*", "translations/*"]
99
}
1010

1111
include "root" {
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"common": {
3+
"title": "FUTO Internal"
4+
},
5+
"register": {
6+
"description": "Create your FUTO account."
7+
},
8+
"device": {
9+
"request": {
10+
"disclaimer": "By clicking Allow, you allow {appName} to use your information in accordance with its respective terms of service and privacy policy. You can revoke this access at any time."
11+
}
12+
}
13+
}

0 commit comments

Comments
 (0)