Improve MineGeo exploration and mapping features
This commit is contained in:
212
lib/account-api.ts
Normal file
212
lib/account-api.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import "server-only";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { admin, authClient, required, user } from "./supabase";
|
||||
import { accountProfile } from "./profile";
|
||||
import {
|
||||
authActionSchema,
|
||||
authDestination,
|
||||
passwordUpdateSchema,
|
||||
profileUpdateSchema,
|
||||
} from "@/shared/account";
|
||||
import { createHash } from "node:crypto";
|
||||
import { recoveryCookie, verifyRecoveryGrant } from "./password-recovery";
|
||||
import { requestOrigin } from "./request-origin";
|
||||
|
||||
type Context = {
|
||||
jsonBody: (r: NextRequest) => Promise<unknown>;
|
||||
limit: (key: string, count: number, seconds: number) => Promise<void>;
|
||||
};
|
||||
const json = (body: unknown, status = 200) =>
|
||||
NextResponse.json(body, { status, headers: { "Cache-Control": "no-store" } });
|
||||
export async function accountApi(
|
||||
request: NextRequest,
|
||||
path: string[],
|
||||
context: Context,
|
||||
) {
|
||||
if (path[0] === "profile" && path.length === 1) {
|
||||
const account = await user();
|
||||
if (!account) return json({ error: "Log in to manage your account" }, 401);
|
||||
if (request.method === "GET")
|
||||
return json({
|
||||
profile: await accountProfile(account),
|
||||
email: account.email,
|
||||
});
|
||||
if (request.method === "PATCH") {
|
||||
const body = profileUpdateSchema.parse(await context.jsonBody(request));
|
||||
await accountProfile(account);
|
||||
const profile = required(
|
||||
await admin()
|
||||
.from("profiles")
|
||||
.update({ display_name: body.displayName })
|
||||
.eq("id", account.id)
|
||||
.select("id,display_name,role")
|
||||
.single(),
|
||||
);
|
||||
return json({ profile });
|
||||
}
|
||||
return json({ error: "Method not allowed" }, 405);
|
||||
}
|
||||
if (path[0] !== "auth" || request.method !== "POST" || path.length > 2)
|
||||
return json({ error: "Not found" }, 404);
|
||||
const client = await authClient();
|
||||
if (path.length === 1) {
|
||||
const body = authActionSchema.parse(await context.jsonBody(request));
|
||||
const identity = createHash("sha256")
|
||||
.update(body.email.toLowerCase())
|
||||
.digest("hex");
|
||||
await context.limit(
|
||||
`auth:${body.action}:${identity}`,
|
||||
body.action === "login" ? 15 : 5,
|
||||
600,
|
||||
);
|
||||
const origin = requestOrigin(request, process.env.MINEGEO_URL);
|
||||
const next = authDestination("next" in body ? body.next : null);
|
||||
const callback = new URL("/auth/callback", origin);
|
||||
callback.searchParams.set(
|
||||
"next",
|
||||
body.action === "recover"
|
||||
? "/auth/reset-password"
|
||||
: next === "/auth/reset-password"
|
||||
? "/dashboard"
|
||||
: next,
|
||||
);
|
||||
if (body.action === "login") {
|
||||
const { error } = await client.auth.signInWithPassword({
|
||||
email: body.email,
|
||||
password: body.password,
|
||||
});
|
||||
if (error)
|
||||
return json(
|
||||
{
|
||||
error:
|
||||
error.code === "email_not_confirmed"
|
||||
? "Confirm your email address before logging in."
|
||||
: "Email or password is incorrect. Try again or reset your password.",
|
||||
},
|
||||
401,
|
||||
);
|
||||
return json({ next });
|
||||
}
|
||||
if (body.action === "signup") {
|
||||
const { data, error } = await client.auth.signUp({
|
||||
email: body.email,
|
||||
password: body.password,
|
||||
options: {
|
||||
data: { display_name: body.displayName },
|
||||
emailRedirectTo: callback.toString(),
|
||||
},
|
||||
});
|
||||
if (error) throw new Error(error.message);
|
||||
if (data.session && data.user) {
|
||||
await accountProfile(data.user);
|
||||
return json({ next });
|
||||
}
|
||||
return json({
|
||||
message:
|
||||
"Check your email to confirm your account. If you already have an account, log in or reset your password.",
|
||||
});
|
||||
}
|
||||
const { error } =
|
||||
body.action === "recover"
|
||||
? await client.auth.resetPasswordForEmail(body.email, {
|
||||
redirectTo: callback.toString(),
|
||||
})
|
||||
: await client.auth.signInWithOtp({
|
||||
email: body.email,
|
||||
options: {
|
||||
shouldCreateUser: false,
|
||||
emailRedirectTo: callback.toString(),
|
||||
},
|
||||
});
|
||||
if (
|
||||
error &&
|
||||
!["user_not_found", "signup_disabled"].includes(error.code ?? "")
|
||||
)
|
||||
throw new Error(error.message);
|
||||
return json({
|
||||
message:
|
||||
body.action === "recover"
|
||||
? "If an account exists for this email, a password reset link is on its way. Check your inbox."
|
||||
: "If an account exists for this email, a sign-in link is on its way. Check your inbox.",
|
||||
});
|
||||
}
|
||||
if (path[1] !== "password") return json({ error: "Not found" }, 404);
|
||||
const account = await user();
|
||||
if (!account?.email)
|
||||
return json({ error: "Log in before changing your password" }, 401);
|
||||
const body = passwordUpdateSchema.parse(await context.jsonBody(request));
|
||||
await context.limit(`password:${account.id}`, 10, 600);
|
||||
const { data: claimData, error: claimError } = await client.auth.getClaims();
|
||||
const sessionId =
|
||||
typeof claimData?.claims.session_id === "string"
|
||||
? claimData.claims.session_id
|
||||
: "";
|
||||
if (claimError || !sessionId)
|
||||
return json({ error: "Your session expired. Log in again." }, 401);
|
||||
if (body.currentPassword) {
|
||||
// Verify through Auth without replacing this browser's session.
|
||||
const verifier = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
|
||||
{ auth: { persistSession: false, autoRefreshToken: false } },
|
||||
);
|
||||
const { error } = await verifier.auth.signInWithPassword({
|
||||
email: account.email,
|
||||
password: body.currentPassword,
|
||||
});
|
||||
if (error)
|
||||
return json(
|
||||
{
|
||||
error:
|
||||
"Your current password is incorrect. Use an email reset link if you have not set a password yet.",
|
||||
},
|
||||
403,
|
||||
);
|
||||
await verifier.auth.signOut({ scope: "local" });
|
||||
} else if (
|
||||
!verifyRecoveryGrant(
|
||||
request.cookies.get(recoveryCookie)?.value,
|
||||
account.id,
|
||||
sessionId,
|
||||
process.env.SUPABASE_SECRET_KEY!,
|
||||
)
|
||||
) {
|
||||
return json(
|
||||
{
|
||||
error:
|
||||
"Enter your current password or open a fresh password reset link from your email.",
|
||||
},
|
||||
403,
|
||||
);
|
||||
}
|
||||
const { error } = await client.auth.updateUser({
|
||||
password: body.password,
|
||||
...(body.currentPassword ? { current_password: body.currentPassword } : {}),
|
||||
...(body.nonce ? { nonce: body.nonce } : {}),
|
||||
});
|
||||
if (error)
|
||||
return json(
|
||||
{
|
||||
error: error.message,
|
||||
needsReauthentication: error.code === "reauthentication_needed",
|
||||
},
|
||||
400,
|
||||
);
|
||||
const { error: signOutError } = await client.auth.signOut({
|
||||
scope: "others",
|
||||
});
|
||||
const response = json({
|
||||
ok: true,
|
||||
message: signOutError
|
||||
? "Password updated. Other sessions could not be signed out; please try signing out other devices again."
|
||||
: "Password updated. Other devices have been signed out.",
|
||||
});
|
||||
response.cookies.set(recoveryCookie, "", {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
Reference in New Issue
Block a user