Compare commits

...
2 Commits
Author SHA1 Message Date
openhands 4acafcfef6 style(auth): satisfy Biome in password digest helpers
CI / check (push) Successful in 4m17s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m0s
Unescape dollar signs inside character classes and use template literals
instead of string concatenation in the salted digest tests.
2026-09-18 12:44:44 +02:00
openhands d131124515 feat(theme): animate public background with aurora and particles, polish landing pages
Add background_effect (aurora/particles), background_overlay tint and
opacity to the theme manager, rendered site-wide by ThemeVars on every
public page. Polish the home and register pages (hero mascot, live stat
pulse, date pills, photo strip, CTA band, theme-aware register intro,
i18n for home/register section).
2026-09-18 12:44:39 +02:00
11 changed files with 584 additions and 36 deletions

No files matched your search

+52
View File
@@ -12,10 +12,12 @@ import { siteSettings } from "@/lib/services/site-settings";
import { logStaffActivity } from "@/lib/services/staff-activity";
import {
BACKGROUND_DEFAULTS,
BACKGROUND_EFFECTS,
BACKGROUND_POSITIONS,
BACKGROUND_REPEATS,
BACKGROUND_SIZES,
isSafeBackgroundUrl,
OVERLAY_OPACITY_MAX,
} from "@/lib/theme-bg";
import {
type CustomTheme,
@@ -187,6 +189,17 @@ export async function saveBackground(formData: FormData): Promise<void> {
const repeat = String(formData.get("background_repeat") ?? "")
.normalize("NFC")
.trim();
const effect = String(formData.get("background_effect") ?? "")
.normalize("NFC")
.trim();
const overlay = String(formData.get("background_overlay") ?? "")
.normalize("NFC")
.trim();
const overlayOpacity = String(
formData.get("background_overlay_opacity") ?? "",
)
.normalize("NFC")
.trim();
if (image && !isSafeBackgroundUrl(image)) redirect(MANAGER_PATH);
@@ -210,6 +223,22 @@ export async function saveBackground(formData: FormData): Promise<void> {
? repeat
: BACKGROUND_DEFAULTS.background_repeat,
);
await writeSetting(
"background_effect",
(BACKGROUND_EFFECTS as readonly string[]).includes(effect)
? effect
: BACKGROUND_DEFAULTS.background_effect,
);
await writeSetting(
"background_overlay",
/^#[0-9a-fA-F]{3,8}$/.test(overlay) ? overlay : "",
);
await writeSetting(
"background_overlay_opacity",
/^\d{1,2}$/.test(overlayOpacity)
? String(Math.min(parseInt(overlayOpacity, 10), OVERLAY_OPACITY_MAX))
: "0",
);
siteSettings.reload();
revalidatePath("/", "layout");
revalidatePath(MANAGER_PATH);
@@ -344,6 +373,29 @@ export async function getThemeManagerData() {
)
? (backgroundValues.background_repeat as string)
: BACKGROUND_DEFAULTS.background_repeat,
effect: (BACKGROUND_EFFECTS as readonly string[]).includes(
backgroundValues.background_effect ?? "",
)
? (backgroundValues.background_effect as string)
: BACKGROUND_DEFAULTS.background_effect,
overlay: /^#[0-9a-fA-F]{3,8}$/.test(
backgroundValues.background_overlay ?? "",
)
? (backgroundValues.background_overlay as string)
: "",
overlayOpacity: /^\d{1,2}$/.test(
backgroundValues.background_overlay_opacity ?? "",
)
? String(
Math.min(
parseInt(
backgroundValues.background_overlay_opacity as string,
10,
),
OVERLAY_OPACITY_MAX,
),
)
: "0",
},
};
}
+107 -9
View File
File diff suppressed because it is too large. Load diff
+12 -10
View File
@@ -65,16 +65,16 @@ export default async function RegisterPage() {
<div
className="absolute inset-0"
style={{
background: `url(/assets/images/background.png) center/cover no-repeat`,
background:
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, transparent), color-mix(in srgb, var(--color-accent) 10%, transparent))",
}}
/>
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(135deg, color-mix(in srgb, var(--color-surface) 88%, transparent) 0%, color-mix(in srgb, var(--color-surface) 35%, transparent) 100%)",
}}
/>
aria-hidden="true"
className="absolute inset-0 overflow-hidden"
>
<div className="brand-halo left-1/2 top-0 h-64 w-[460px] max-w-full -translate-x-1/2 -translate-y-1/2" />
</div>
<div className="relative">
<div className="flex items-center gap-4">
<div className="shrink-0 animate-float">
@@ -135,7 +135,8 @@ export default async function RegisterPage() {
{recentUsers.map((u) => (
<div
key={u.username}
className="flex flex-col items-center gap-1.5 rounded-xl border px-1 py-2 transition-all duration-200 hover:-translate-y-0.5"
title={u.username}
className="flex flex-col items-center gap-1.5 rounded-xl border px-1 py-2 transition-all duration-200 hover:-translate-y-0.5 hover:ring-1 hover:ring-[color-mix(in_srgb,var(--color-primary)_35%,transparent)]"
style={{
background:
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
@@ -163,7 +164,7 @@ export default async function RegisterPage() {
{latestUsers.length > 0 && (
<SurfaceCard
title="Newest citizens"
title={tpr("newestCitizens")}
icon={ICON_FRIENDS}
bodyClassName="p-4"
className="card-glow"
@@ -172,7 +173,8 @@ export default async function RegisterPage() {
{latestUsers.map((u) => (
<div
key={u.username}
className="flex flex-col items-center gap-1.5 rounded-xl border px-1 py-2 transition-all duration-200 hover:-translate-y-0.5"
title={u.username}
className="flex flex-col items-center gap-1.5 rounded-xl border px-1 py-2 transition-all duration-200 hover:-translate-y-0.5 hover:ring-1 hover:ring-[color-mix(in_srgb,var(--color-primary)_35%,transparent)]"
style={{
background:
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
+142
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+8 -8
View File
@@ -169,36 +169,36 @@ describe("isCombinedDigestOf", () => {
describe("isSaltedDigestOf", () => {
it("verifies md5(salt+password) with hash:salt layout", async () => {
const salt = "pepper123";
const stored = `${await md5Hex(salt + "oldpass")}:${salt}`;
const stored = `${await md5Hex(`${salt}oldpass`)}:${salt}`;
expect(await isSaltedDigestOf("oldpass", stored)).toBe(true);
expect(await isSaltedDigestOf("wrong", stored)).toBe(false);
});
it("verifies md5(password+salt) with hash:salt layout", async () => {
const salt = "pepper123";
const stored = `${await md5Hex("oldpass" + salt)}:${salt}`;
const stored = `${await md5Hex(`oldpass${salt}`)}:${salt}`;
expect(await isSaltedDigestOf("oldpass", stored)).toBe(true);
});
it("verifies the salt:hash layout", async () => {
const salt = "abc123";
const stored = `${salt}:${await md5Hex(salt + "oldpass")}`;
const stored = `${salt}:${await md5Hex(`${salt}oldpass`)}`;
expect(await isSaltedDigestOf("oldpass", stored)).toBe(true);
expect(await isSaltedDigestOf("wrong", stored)).toBe(false);
});
it("verifies the hash$salt layout", async () => {
const salt = "s0lt_9";
const stored = `${await md5Hex("oldpass" + salt)}$s0lt_9`;
const stored = `${await md5Hex(`oldpass${salt}`)}$s0lt_9`;
expect(await isSaltedDigestOf("oldpass", stored)).toBe(true);
});
it("verifies salted sha1 and sha256 digests", async () => {
const salt = "pepper123";
const sha1Stored = `${await sha1Hex(salt + "oldpass")}:${salt}`;
const sha1Stored = `${await sha1Hex(`${salt}oldpass`)}:${salt}`;
expect(await isSaltedDigestOf("oldpass", sha1Stored)).toBe(true);
const sha256Stored = `${await sha256Hex("oldpass" + salt)}:${salt}`;
const sha256Stored = `${await sha256Hex(`oldpass${salt}`)}:${salt}`;
expect(await isSaltedDigestOf("oldpass", sha256Stored)).toBe(true);
});
@@ -268,7 +268,7 @@ describe("checkLogin", () => {
it("migrates a salted md5 hash to bcrypt (md5(salt+password))", async () => {
const salt = "pepper123";
const stored = `${await md5Hex(salt + "oldpass")}:${salt}`;
const stored = `${await md5Hex(`${salt}oldpass`)}:${salt}`;
const res = await checkLogin("oldpass", stored);
expect(res.valid).toBe(true);
expect(res.upgradedHash).toMatch(/^\$2[aby]\$/);
@@ -279,7 +279,7 @@ describe("checkLogin", () => {
it("migrates a salted sha256 hash to bcrypt (sha256(salt+password))", async () => {
const salt = "abc123";
const stored = `${salt}:${await sha256Hex(salt + "oldpass")}`;
const stored = `${salt}:${await sha256Hex(`${salt}oldpass`)}`;
const res = await checkLogin("oldpass", stored);
expect(res.valid).toBe(true);
expect(res.upgradedHash).toMatch(/^\$2[aby]\$/);
+3 -3
View File
@@ -130,7 +130,7 @@ export async function isCombinedDigestOf(
* sha256, sha512) is tried, and both md5(salt+password) and md5(password+salt)
* orderings are verified to cover both conventions.
*/
const SALTED_HASH_DELIMITER_RE = /[:\$@_]/;
const SALTED_HASH_DELIMITER_RE = /[:$@_]/;
export async function isSaltedDigestOf(
password: string,
@@ -140,10 +140,10 @@ export async function isSaltedDigestOf(
for (const { length, hash: hashFn } of DIGEST_SCHEMES) {
const hashFirst = stored.match(
new RegExp(`^([a-f0-9]{${length}})[:\$@_](.{1,64})$`, "i"),
new RegExp(`^([a-f0-9]{${length}})[:$@_](.{1,64})$`, "i"),
);
const saltFirst = stored.match(
new RegExp(`^(.{1,64})[:\$@_]([a-f0-9]{${length}})$`, "i"),
new RegExp(`^(.{1,64})[:$@_]([a-f0-9]{${length}})$`, "i"),
);
const hashHex = hashFirst?.[1] ?? saltFirst?.[2];
const salt = hashFirst?.[2] ?? saltFirst?.[1];
+31
View File
@@ -7,9 +7,37 @@ export const BACKGROUND_KEYS = [
"background_position",
"background_size",
"background_repeat",
"background_effect",
"background_overlay",
"background_overlay_opacity",
] as const;
export type BackgroundKey = (typeof BACKGROUND_KEYS)[number];
/**
* Animated background effects rendered behind the public site. Purely
* decorative and CSS-driven (transform/opacity only) so they stay cheap on
* every device and respect `prefers-reduced-motion`.
*/
export const BACKGROUND_EFFECTS = [
"none",
"aurora",
"particles",
"aurora-particles",
] as const;
export type BackgroundEffect = (typeof BACKGROUND_EFFECTS)[number];
export const BACKGROUND_EFFECT_DEFAULT: BackgroundEffect = "none";
export const OVERLAY_PRESETS = [
{ label: "None", value: "" },
{ label: "Black", value: "#000000" },
{ label: "White", value: "#ffffff" },
{ label: "Navy", value: "#0b1220" },
{ label: "Slate", value: "#1e293b" },
] as const;
export const OVERLAY_OPACITY_MAX = 90;
export const BACKGROUND_POSITIONS = [
"center center",
"top center",
@@ -36,6 +64,9 @@ export const BACKGROUND_DEFAULTS: Record<BackgroundKey, string> = {
background_position: "center center",
background_size: "cover",
background_repeat: "no-repeat",
background_effect: BACKGROUND_EFFECT_DEFAULT,
background_overlay: "",
background_overlay_opacity: "0",
};
/**
+15 -1
View File
@@ -288,7 +288,11 @@
"sectionStatsLabel": "Live stats",
"sectionStatsTitle": "A community that keeps growing",
"sectionCommunityLabel": "Community",
"sectionCommunityTitle": "Make yourself at home"
"sectionCommunityTitle": "Make yourself at home",
"joinCtaTitle": "Ready to make yourself at home?",
"joinCtaBody": "Create your avatar, grab some furni and meet new friends — your hotel adventure starts here.",
"ctaJoin": "Join the hotel",
"browseCommunity": "Browse the community"
},
"news": {
"title": "News",
@@ -997,6 +1001,7 @@
"alreadyHaveAccountLink": "Sign in",
"register": "Register",
"whoIsOnline": "Who's online",
"newestCitizens": "Newest citizens",
"usersOnline": "{count} online",
"termsError": "You must accept the terms & rules to register.",
"usernameError": "Username is required.",
@@ -4810,6 +4815,15 @@
"applyBackground": "Apply background",
"uploadButton": "Upload image…",
"clearBackground": "Remove background",
"backgroundEffectLabel": "Effect",
"backgroundEffectHint": "Animate the public background with a gentle, theme-matched layer (aurora uses your primary/accent colors).",
"backgroundEffect_none": "None (plain background)",
"backgroundEffect_aurora": "Aurora glow",
"backgroundEffect_particles": "Floating particles",
"backgroundEffect_aurora-particles": "Aurora + particles",
"backgroundEffectPreview": "Effect: {effect} — shown behind the public site.",
"backgroundOverlayLabel": "Overlay tint",
"backgroundOverlayOpacityLabel": "Overlay strength",
"notice": {
"saved": "Theme saved.",
"applied": "Theme applied.",
+19 -5
View File
@@ -283,12 +283,16 @@
"featuresCommunityDesc": "Fai amicizia e chatta",
"featuresGames": "Giochi",
"featuresGamesDesc": "Competi e divertiti",
"sectionFeaturesLabel": "Features",
"sectionFeaturesTitle": "Everything a great hotel needs",
"sectionStatsLabel": "Live stats",
"sectionStatsTitle": "A community that keeps growing",
"sectionFeaturesLabel": "Funzionalità",
"sectionFeaturesTitle": "Tutto ciò che un grande hotel merita",
"sectionStatsLabel": "Statistiche live",
"sectionStatsTitle": "Una community che continua a crescere",
"sectionCommunityLabel": "Community",
"sectionCommunityTitle": "Make yourself at home"
"sectionCommunityTitle": "Fatti sentire a casa",
"joinCtaTitle": "Pronto a sentirti a casa?",
"joinCtaBody": "Crea il tuo avatar, prendi dei mobili e incontra nuovi amici: la tua avventura nell'hotel inizia qui.",
"ctaJoin": "Entra nell'hotel",
"browseCommunity": "Scopri la community"
},
"news": {
"title": "Notizie",
@@ -976,6 +980,7 @@
"alreadyHaveAccountPre": "Hai già un account?",
"alreadyHaveAccountLink": "Accedi",
"whoIsOnline": "Chi è online",
"newestCitizens": "Nuovi cittadini",
"usersOnline": "{count} online",
"accepted": "Accettato e confermato",
"showPassword": "Mostra",
@@ -4809,6 +4814,15 @@
"applyBackground": "Applica sfondo",
"uploadButton": "Carica immagine…",
"clearBackground": "Rimuovi sfondo",
"backgroundEffectLabel": "Effetto",
"backgroundEffectHint": "Anima lo sfondo pubblico con un livello delicato e abbinato al tema (l'aurora usa i colori primario/accento).",
"backgroundEffect_none": "Nessuno (sfondo semplice)",
"backgroundEffect_aurora": "Bagliore aurora",
"backgroundEffect_particles": "Particelle fluttuanti",
"backgroundEffect_aurora-particles": "Aurora + particelle",
"backgroundEffectPreview": "Effetto: {effect} — mostrato dietro il sito pubblico.",
"backgroundOverlayLabel": "Tinta di sovrapposizione",
"backgroundOverlayOpacityLabel": "Intensità tinta",
"notice": {
"saved": "Tema salvato.",
"applied": "Tema applicato.",