Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/actions/install-dependencies/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ runs:

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24.21.0"
node-version: "24"

- name: Install dependencies
shell: bash
Expand Down
2 changes: 1 addition & 1 deletion .github/renovate.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended", "helpers:pinGitHubActionDigests"],
"enabledManagers": ["bun", "github-actions", "dockerfile", "custom.regex"],
"minimumReleaseAge": "3 days",
"minimumReleaseAge": "2 days",
"rangeStrategy": "bump",
"customManagers": [
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Settings } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { CardContent, CardDescription, CardTitle } from "~/client/components/ui/card";
import { Label } from "~/client/components/ui/label";
Expand All @@ -14,8 +15,11 @@ import {
} from "~/client/lib/datetime";

export function DateTimeFormatSection() {
const [previewDate] = useState(() => new Date());

const { dateFormat, timeFormat } = useRootLoaderData();
const { formatDateTime } = useTimeFormat();

const handleDateTimeFormatChange = async (
nextDateFormat: DateFormatPreference,
nextTimeFormat: TimeFormatPreference,
Expand Down Expand Up @@ -103,7 +107,7 @@ export function DateTimeFormatSection() {
</Select>
</div>
</div>
<p className="text-sm text-muted-foreground">Preview: {formatDateTime(new Date())}</p>
<p className="text-sm text-muted-foreground">Preview: {formatDateTime(previewDate)}</p>
</div>
</CardContent>
</>
Expand Down
55 changes: 52 additions & 3 deletions app/server/cli/commands/__tests__/reset-password.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, test } from "vitest";
import { hashPassword } from "better-auth/crypto";
import { createApp } from "~/server/app";
import { config } from "~/server/core/config";
import { db } from "~/server/db/db";
Expand Down Expand Up @@ -116,26 +117,74 @@ describe("resetPassword", () => {
},
);

test("creates a usable credential account when the user has no password account", async () => {
test.each([8, 128])("creates a usable credential account with a %i-character password", async (length) => {
const password = "a".repeat(length);
const userId = Bun.randomUUIDv7();

await db.insert(usersTable).values({
id: userId,
username: "sso-user",
email: "sso-user@example.com",
name: "SSO User",
});

await resetPassword("sso-user", "replacement-password");
await resetPassword("sso-user", password);

const response = await app.request("/api/auth/sign-in/username", {
method: "POST",
headers: {
"Content-Type": "application/json",
Origin: config.baseUrl,
},
body: JSON.stringify({ username: "sso-user", password: "replacement-password" }),
body: JSON.stringify({ username: "sso-user", password }),
});

expect(response.status).toBe(200);
});

test.each([
[7, "Password must be at least 8 characters long"],
[129, "Password must be at most 128 characters long"],
] as const)("rejects a %i-character password without changing credentials or sessions", async (length, message) => {
const userId = Bun.randomUUIDv7();
const oldPassword = "existing-password";

const [user] = await db
.insert(usersTable)
.values({
id: userId,
username: "legacy-user",
email: "legacy@example.com",
name: "Legacy User",
passwordHash: await Bun.password.hash(oldPassword),
})
.returning();

const [credential] = await db
.insert(account)
.values({
id: Bun.randomUUIDv7(),
accountId: userId,
providerId: "credential",
userId,
password: await hashPassword(oldPassword),
})
.returning();

const [session] = await db
.insert(sessionsTable)
.values({
id: Bun.randomUUIDv7(),
userId,
token: "existing-session",
expiresAt: new Date(Date.now() + 60_000),
})
.returning();

await expect(resetPassword("legacy-user", "a".repeat(length))).rejects.toThrow(message);

expect(await db.query.usersTable.findFirst({ where: { id: userId } })).toEqual(user);
expect(await db.query.account.findMany({ where: { userId } })).toEqual([credential]);
expect(await db.query.sessionsTable.findMany({ where: { userId } })).toEqual([session]);
});
});
27 changes: 18 additions & 9 deletions app/server/cli/commands/reset-password.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,28 @@ import { toMessage } from "~/server/utils/errors";
import { db } from "../../db/db";
import { account, sessionsTable, usersTable } from "../../db/schema";

const validatePassword = (value: string) => {
if (value.length < 8) {
return "Password must be at least 8 characters long";
}
if (value.length > 128) {
return "Password must be at most 128 characters long";
}

return true;
};

const listUsers = () => {
return db.select({ id: usersTable.id, username: usersTable.username }).from(usersTable);
};

export const resetPassword = async (username: string, newPassword: string) => {
const validation = validatePassword(newPassword);

if (validation !== true) {
throw new Error(validation);
}

const [user] = await db.select().from(usersTable).where(eq(usersTable.username, username));

if (!user) {
Expand Down Expand Up @@ -95,12 +112,7 @@ export const resetPasswordCommand = new Command("reset-password")
newPassword = await password({
message: "Enter new password:",
mask: "*",
validate: (value) => {
if (value.length < 8) {
return "Password must be at least 8 characters long";
}
return true;
},
validate: validatePassword,
});

const confirmPassword = await password({
Expand All @@ -112,9 +124,6 @@ export const resetPasswordCommand = new Command("reset-password")
console.error("\n❌ Passwords do not match.");
process.exit(1);
}
} else if (newPassword.length < 8) {
console.error("\n❌ Password must be at least 8 characters long.");
process.exit(1);
}

try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createApp } from "~/server/app";
import { config } from "~/server/core/config";
import { db } from "~/server/db/db";
import { account, member, organization, sessionsTable, usersTable } from "~/server/db/schema";
import { resetPassword } from "~/server/cli/commands/reset-password";

const app = createApp();

Expand All @@ -14,6 +15,44 @@ beforeEach(async () => {
await db.delete(usersTable);
});

test("a legacy user with an overlong password can recover access using the CLI reset", async () => {
const username = "legacy-admin";
const oldPassword = "a".repeat(129);
const newPassword = "a".repeat(128);

await db.insert(usersTable).values({
id: Bun.randomUUIDv7(),
username,
email: "legacy-admin@example.com",
name: "Legacy Admin",
passwordHash: await Bun.password.hash(oldPassword),
});

const signIn = (password: string) =>
app.request("/api/auth/sign-in/username", {
method: "POST",
headers: {
"Content-Type": "application/json",
Origin: config.baseUrl,
},
body: JSON.stringify({ username, password }),
});

const rejectedResponse = await signIn(oldPassword);

expect(rejectedResponse.status).toBe(400);
expect(await rejectedResponse.json()).toMatchObject({ code: "PASSWORD_TOO_LONG" });

await resetPassword(username, newPassword);

const recoveredResponse = await signIn(newPassword);

expect(recoveredResponse.status).toBe(200);

const recoveredUser = await db.query.usersTable.findFirst({ where: { username } });
expect(await db.query.sessionsTable.findFirst({ where: { userId: recoveredUser?.id } })).toBeDefined();
});

test("a legacy user can sign in through Better Auth during first-login conversion", async () => {
const username = "legacy-admin";
const password = "legacy-password";
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@
"reflect-metadata": "0.2.2"
},
"devDependencies": {
"electron": "^44.4.4",
"electron": "^44.5.0",
"electron-builder": "^26.15.3",
"typescript": "^7.0.2",
"vite": "^8.3.0"
"vite": "^8.3.1"
}
}
22 changes: 14 additions & 8 deletions apps/docs/content/docs/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,22 @@ docker exec -it zerobyte bun run cli reset-password --help

Most commands prompt for missing values when run interactively.

## Password Reset

New passwords must be between 8 and 128 characters long. Invalid passwords are rejected without changing the existing password or invalidating sessions.

A successful reset invalidates all existing sessions for that user.

## Commands

| Command | Useful for |
|---------|------------|
| `reset-password` | Reset a user's password when they are locked out or forgot it. Invalidates existing sessions. |
| `disable-2fa` | Disable two-factor authentication for a user who lost access to their 2FA device. |
| `change-username` | Rename a user account. Invalidates existing sessions. |
| `change-email` | Change a user's email address. Removes linked SSO accounts for that user and invalidates sessions. |
| `rekey-2fa` | Re-encrypt existing 2FA secrets using the current `APP_SECRET`, usually after migrating from an older secret setup. |
| `assign-organization` | Move a user to another organization by organization slug. Invalidates existing sessions. |
| Command | Useful for |
| --------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `reset-password` | Reset a user's password when they are locked out or forgot it. Invalidates existing sessions. |
| `disable-2fa` | Disable two-factor authentication for a user who lost access to their 2FA device. |
| `change-username` | Rename a user account. Invalidates existing sessions. |
| `change-email` | Change a user's email address. Removes linked SSO accounts for that user and invalidates sessions. |
| `rekey-2fa` | Re-encrypt existing 2FA secrets using the current `APP_SECRET`, usually after migrating from an older secret setup. |
| `assign-organization` | Move a user to another organization by organization slug. Invalidates existing sessions. |

## Examples

Expand Down
26 changes: 13 additions & 13 deletions apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,19 @@
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@phosphor-icons/react": "^2.1.10",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-devtools": "^0.10.12",
"@tanstack/react-router": "^1.170.38",
"@tanstack/react-devtools": "^0.10.13",
"@tanstack/react-router": "^1.170.41",
"@tanstack/react-router-devtools": "^1.167.2",
"@tanstack/react-router-ssr-query": "^1.167.3",
"@tanstack/react-start": "^1.168.57",
"@tanstack/router-plugin": "^1.168.40",
"@tanstack/react-start": "^1.168.60",
"@tanstack/router-plugin": "^1.168.42",
"@types/mdx": "^2.0.14",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"fumadocs-core": "^16.15.13",
"fumadocs-mdx": "^15.4.3",
"fumadocs-ui": "^16.15.13",
"lucide-react": "^1.47.0",
"fumadocs-core": "^16.15.17",
"fumadocs-mdx": "^15.4.5",
"fumadocs-ui": "^16.15.17",
"lucide-react": "^1.49.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"shadcn": "^4.21.0",
Expand All @@ -39,21 +39,21 @@
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@cloudflare/vite-plugin": "^1.57.3",
"@cloudflare/vite-plugin": "^1.62.2",
"@tailwindcss/typography": "^0.5.20",
"@tanstack/devtools-vite": "^0.8.5",
"@testing-library/dom": "^10.4.2",
"@testing-library/react": "^16.3.3",
"@types/node": "^26.6.2",
"@types/node": "^26.6.3",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
"jsdom": "^30.1.1",
"typescript": "^7.0.2",
"vite": "^8.3.0",
"vite": "^8.3.1",
"vite-plugin-killer-instincts": "^1.1.0",
"vitest": "^5.0.1",
"wrangler": "^4.136.3"
"vitest": "^5.0.2",
"wrangler": "^4.144.0"
},
"pnpm": {
"onlyBuiltDependencies": [
Expand Down
Loading
Loading