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
85 changes: 85 additions & 0 deletions apps/web/app/actions/profile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"use server";

import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";

import { schema } from "@codev/db";

import { unstable_update } from "@/auth";
import { getNewAccountPasswordError } from "@/lib/auth/password-policy";
import { requireUser } from "@/lib/auth/session";
import { hashPassword, verifyPassword } from "@/lib/platform/crypto";
import { getDatabase } from "@/lib/platform/database";

const PROFILE_PATH = "/settings/personal/profile";
const MAX_DISPLAY_NAME_LENGTH = 80;

export async function updateDisplayName(formData: FormData) {
const user = await requireUser();
const name = String(formData.get("name") ?? "")
.trim()
.replace(/\s+/g, " ");

if (!name || name.length > MAX_DISPLAY_NAME_LENGTH) {
redirect(`${PROFILE_PATH}?error=name`);
}

await getDatabase()
.update(schema.users)
.set({ name, updatedAt: new Date() })
.where(eq(schema.users.id, user.id));

// The session token carries the name shown in the app shell. Refreshing it
// is best effort: the database is already correct, and the token catches up
// at the next sign-in if this fails.
try {
await unstable_update({ user: { name } });
} catch {
// Intentionally ignored.
}

revalidatePath("/settings", "layout");
redirect(`${PROFILE_PATH}?name=saved`);
}

/**
* Changes the password of an account that already has one. Unlike
* `setAccountPassword`, which only ever fills an empty hash, this requires the
* current password, so a hijacked session alone cannot overwrite it.
*/
export async function changeAccountPassword(
redirectTo: string,
formData: FormData,
) {
const user = await requireUser();
const current = String(formData.get("current") ?? "");
const password = String(formData.get("password") ?? "");
const confirm = String(formData.get("confirm") ?? "");

const [row] = await getDatabase()
.select({ passwordHash: schema.users.passwordHash })
.from(schema.users)
.where(eq(schema.users.id, user.id))
.limit(1);

if (!row?.passwordHash) {
redirect(`${redirectTo}?error=nopassword`);
}
if (!(await verifyPassword(current, row.passwordHash))) {
redirect(`${redirectTo}?error=current`);
}
if (password !== confirm) {
redirect(`${redirectTo}?error=match`);
}
if (getNewAccountPasswordError(password)) {
redirect(`${redirectTo}?error=policy`);
}

await getDatabase()
.update(schema.users)
.set({ passwordHash: await hashPassword(password), updatedAt: new Date() })
.where(eq(schema.users.id, user.id));

redirect(`${redirectTo}?password=changed`);
}
66 changes: 66 additions & 0 deletions apps/web/app/api/settings/export/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { eq } from "drizzle-orm";

import { schema } from "@codev/db";

import { getConnectedAccounts } from "@/lib/auth/identity";
import { apiError, getApiUser } from "@/lib/http/api";
import { getDatabase } from "@/lib/platform/database";
import { listUserEnvironmentVariables } from "@/lib/providers/user-environment";

/**
* A copy of the account facts a member can see in Settings. Secrets are never
* included: environment variables are listed by name only, and provider
* credentials are not exported at all.
*/
export async function GET() {
const user = await getApiUser();
if (!user) return apiError(new Error("Authentication required."), 401);

try {
const [row] = await getDatabase()
.select({
name: schema.users.name,
email: schema.users.email,
login: schema.users.login,
createdAt: schema.users.createdAt,
})
.from(schema.users)
.where(eq(schema.users.id, user.id))
.limit(1);
const accounts = await getConnectedAccounts(user.id);
const variables = await listUserEnvironmentVariables(user.id);

const body = {
exportedAt: new Date().toISOString(),
profile: {
id: user.id,
name: row?.name ?? null,
email: row?.email ?? null,
login: row?.login ?? null,
createdAt: row?.createdAt?.toISOString() ?? null,
},
signInMethods: {
google: accounts.google.connected,
github: accounts.github.connected
? { login: accounts.github.login ?? null }
: false,
password: accounts.hasPassword,
},
environmentVariables: variables.map((variable) => ({
name: variable.name,
createdAt: variable.createdAt,
updatedAt: variable.updatedAt,
})),
};

return new Response(JSON.stringify(body, null, 2), {
headers: {
"Content-Type": "application/json",
"Content-Disposition": 'attachment; filename="codev-account.json"',
"Cache-Control": "no-store",
},
});
} catch (error) {
return apiError(error);
}
}
74 changes: 74 additions & 0 deletions apps/web/app/app-theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,67 @@
position: relative;
}

/* Hide/show control. Expanded, it floats over the sidebar header's right edge;
collapsed, the rail becomes a slim column that keeps it reachable. */
.app-sidebar-rail {
position: fixed;
top: 14px;
left: 176px;
z-index: 30;
}

.app-sidebar-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 0;
border-radius: 8px;
background: transparent;
color: inherit;
cursor: pointer;
opacity: 0.7;
transition:
background-color 150ms ease,
opacity 150ms ease;
}

.app-sidebar-toggle:hover {
background: rgba(var(--ink-rgb), 0.08);
opacity: 1;
}

.app-sidebar-toggle:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
opacity: 1;
}

/* Collapsed: no column is reserved. The toggle floats over the top-left corner
with its own surface so it is visible on any page; pages that put content
there (the settings nav) offset themselves via the collapsed class. */
.app-with-sidebar.is-sidebar-collapsed {
grid-template-columns: minmax(0, 1fr);
}

.app-with-sidebar.is-sidebar-collapsed .app-sidebar {
display: none;
}

.app-with-sidebar.is-sidebar-collapsed .app-sidebar-rail {
top: 10px;
left: 10px;
}

.app-with-sidebar.is-sidebar-collapsed .app-sidebar-toggle {
border: 1px solid var(--line);
background: rgba(var(--paper-rgb), 0.92);
color: var(--ink);
opacity: 1;
backdrop-filter: blur(8px);
}

.dashboard-shell {
min-height: 100dvh;
padding: clamp(32px, 5vw, 64px) clamp(20px, 5vw, 64px) 96px;
Expand Down Expand Up @@ -794,6 +855,19 @@
grid-template-rows: auto auto;
}

/* The rail is a desktop affordance; the mobile bar is always shown. */
.app-sidebar-rail {
display: none;
}

.app-with-sidebar.is-sidebar-collapsed {
grid-template-columns: 1fr;
}

.app-with-sidebar.is-sidebar-collapsed .app-sidebar {
display: flex;
}

.app-sidebar {
flex-direction: row;
align-items: center;
Expand Down
Loading
Loading