Mobile support to come.
"use server";
import { refresh } from "next/cache";
import { redirect } from "next/navigation";
import {
authorizeDevice,
createAnswer,
createAnswerComment,
createQuestion,
createQuestionComment,
createRepository,
getCurrentUser,
hasUser,
updateAnswer,
updateComment,
updateQuestion,
voteAnswer,
voteComment,
voteQuestion,
} from "@/lib/dal";
import { createSupabaseClient } from "@/lib/supabase";
import type {
AnswerResponse,
CommentResponse,
CreateRepositoryResponse,
QuestionResponse,
UserResponse,
VoteResponse,
} from "./lib/dto";
import { delay, validateEmail } from "./util";
export async function getCurrentUserAction(): Promise<UserResponse | null> {
return await getCurrentUser();
}
export type AuthActionResult = { success: true } | { error: string };
export async function login(
_prev: AuthActionResult | null,
formData: FormData,
): Promise<AuthActionResult> {
const supabase = await createSupabaseClient();
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const redirectTo = formData.get("redirect") as string;
if (!validateEmail(email)) {
return await delay(300, { error: "Invalid email" });
}
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) return { error: error.message };
if (redirectTo) redirect(redirectTo);
return { success: true };
}
export async function signup(
_prev: AuthActionResult | null,
formData: FormData,
): Promise<AuthActionResult> {
const supabase = await createSupabaseClient();
const email = formData.get("email") as string;
const username = formData.get("username") as string;
const redirectTo = formData.get("redirect") as string;
if (!validateEmail(email)) {
return await delay(300, { error: "Invalid email" })
};
const usernameError = await validateUsername(username);
if (usernameError) {
return { error: usernameError };
}
// note: this will _not_ fail if the user already exists, but instead send a sign-in link
// we don't differentiate between new and existing for security: otherwise attackers would be able to tell what
// user exists / doesn't exist
const { error } = await supabase.auth.signInWithOtp({
email,
options: { shouldCreateUser: true, data: { username } },
});
if (error) return { error: error.message };
if (redirectTo) redirect(redirectTo);
return { success: true };
}
async function validateUsername(username: string): Promise<string | null> {
if (username.length < 2) {
return await delay(300, "Username must be at least 2 characters");
}
if (username.length > 32) {
return await delay(300, "Username must be at most 32 characters");
}
if (username.startsWith("-")) {
return await delay(300, "Username cannot start with a hyphen");
}
if (username.endsWith("-")) {
return await delay(300, "Username cannot start with a hyphen");
}
const invalidChars = username.match(/[^a-zA-Z0-9_-]/g);
if (invalidChars) {
return await delay(300, `Username cannot include '${[...new Set(invalidChars)].join('')}'`);
}
const usernameTaken = await hasUser(username);
if (usernameTaken) {
return "Username taken";
}
return null;
}
export async function signout() {
const supabase = await createSupabaseClient();
const { error } = await supabase.auth.signOut();
console.log(error);
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////
// note that the actions here use refresh() as opposed to revalidatePath()
// the reason why is that refresh() only ensures that the current request gets fresh data
// whereas revalidatePath invalidates the entire client-side router cache regardless of the path passed in
// this means things like instant back/forth and prefetches will not work if an action is invoked
// even though it should only selectively dump that path in the client
//
// so rather dumbly, we just use refresh() which sets FreshnessPolicy.RefreshAll for the current navigation only
//////////////////////////////////////////////////////////////////////////////////////////////////////////
export type CreateRepositoryActionResult =
| { repository: CreateRepositoryResponse }
| { error: string };
export async function createRepositoryAction(
formData: FormData,
): Promise<CreateRepositoryActionResult> {
const owner = formData.get("owner") as string;
const name = formData.get("repo-name") as string;
const visibility = formData.get("visibility") as string;
if (!owner || !name) {
return { error: "Owner and repository name are required" };
}
const result = await createRepository(owner, name, {
owner_type: "user",
visibility,
});
if (!result) {
return { error: "Failed to create repository" };
}
refresh();
return { repository: result };
}
export type CreateQuestionActionResult =
| { question: QuestionResponse }
| { error: string };
export async function createQuestionAction(
owner: string,
repo: string,
formData: FormData,
): Promise<CreateQuestionActionResult> {
const title = formData.get("title") as string;
const body = formData.get("body") as string;
if (!title || !body) {
return { error: "Title and body are required" };
}
const result = await createQuestion(owner, repo, { title, body });
if (!result) {
return { error: "createQuestion call failed" };
}
refresh();
return { question: result };
}
export type UpdateQuestionActionResult =
| { question: QuestionResponse }
| { error: string };
export async function updateQuestionAction(
owner: string,
repo: string,
number: number,
formData: FormData,
): Promise<UpdateQuestionActionResult> {
const title = formData.get("title") as string;
const body = formData.get("body") as string;
if (!title || !body) {
return { error: "Title and body are required" };
}
const result = await updateQuestion(owner, repo, number, { title, body });
if (!result) {
return { error: "updateQuestion call failed" };
}
refresh();
return { question: result };
}
export type CreateAnswerActionResult =
| { answer: AnswerResponse }
| { error: string };
export async function createAnswerAction(
owner: string,
repo: string,
number: number,
formData: FormData,
): Promise<CreateAnswerActionResult> {
const body = formData.get("body") as string;
if (!body) {
return { error: "Body cannot be empty" };
}
const result = await createAnswer(owner, repo, number, { body });
if (!result) {
return { error: "createAnswer call failed" };
}
refresh();
return { answer: result };
}
export type UpdateAnswerActionResult =
| { answer: AnswerResponse }
| { error: string };
export async function updateAnswerAction(
owner: string,
repo: string,
number: number,
answerId: string,
formData: FormData,
): Promise<UpdateAnswerActionResult> {
const body = formData.get("body") as string;
if (!body) {
return { error: "Body cannot be empty" };
}
const result = await updateAnswer(owner, repo, number, answerId, { body });
if (!result) {
return { error: "updateAnswer call failed" };
}
refresh();
return { answer: result };
}
export type CreateCommentActionResult =
| { comment: CommentResponse }
| { error: string };
export async function createCommentAction(
owner: string,
repo: string,
number: number,
parentType: "question" | "answer",
parentId: string | undefined,
formData: FormData,
): Promise<CreateCommentActionResult> {
const body = formData.get("body") as string;
if (!body) {
return { error: "Body cannot be empty" };
} else if (parentType === "answer" && !parentId) {
return { error: "parentId is required if parentType is answer" };
}
const result =
parentType === "question"
? await createQuestionComment(owner, repo, Number(number), { body })
: await createAnswerComment(owner, repo, Number(number), parentId!, {
body,
});
if (!result) {
return { error: "createComment call failed" };
}
refresh();
return { comment: result };
}
export type UpdateCommentActionResult =
| { comment: CommentResponse }
| { error: string };
export async function updateCommentAction(
owner: string,
repo: string,
number: number,
commentId: string,
formData: FormData,
): Promise<UpdateCommentActionResult> {
const body = formData.get("body") as string;
if (!body) {
return { error: "Body cannot be empty" };
}
const result = await updateComment(owner, repo, number, commentId, { body });
if (!result) {
return { error: "updateComment call failed" };
}
refresh();
return { comment: result };
}
export type VoteActionResult = { vote: VoteResponse } | { error: string };
export async function voteAction(
owner: string,
repo: string,
number: number,
targetId: string | undefined,
targetType: "question" | "answer" | "comment",
formData: FormData,
): Promise<VoteActionResult> {
const value = Number(formData.get("value"));
if (!targetId && targetType !== "question") {
return { error: `targetId must be set for target type ${targetType}` };
}
let result: VoteResponse | null;
if (targetType === "question") {
result = await voteQuestion(owner, repo, number, { value });
} else if (targetType === "answer") {
result = await voteAnswer(owner, repo, number, targetId!, { value });
} else {
result = await voteComment(owner, repo, number, targetId!, { value });
}
if (!result) {
return { error: "voteAction call failed" };
}
refresh();
return { vote: result };
}
export type AuthorizeDeviceActionResult =
| { success: true }
| { success: false; error: string };
export async function authorizeDeviceAction(
userCode: string,
): Promise<AuthorizeDeviceActionResult> {
if (!userCode) {
return { success: false, error: "User code is required" };
}
const success = await authorizeDevice({ user_code: userCode });
if (!success) {
return { success: false, error: "Failed to authorize device" };
}
return { success: true };
}
wip - wire up our user dto creation
baepaul•1714b5b1d ago
making signup form also work without js, minimal as is
baepaul•d3a1bea1d ago
refactoring validate user -> hasUser and wiring up signup form
baepaul•3fea7211d ago
updating sign up to use signin with otp instead
baepaul•7d504ea1d ago
for the subways
baepaul•96d95bd2d ago
refactoring to head -> /user/{username} for username existence checks
baepaul•2aaa7a42d ago
replaced create_user api with validate_name api
mikkel•0e0bf523d ago
created temporary signup page
mikkel•5a9138b3d ago
doing something risky and ill-advised :)
baepaul•b8881596d ago
authorize device form
baepaul•19943dc7d ago
styling login form + re organizing auth paths
baepaul•b49ac0c7d ago
nits, fixing answer form flicker and stuff
baepaul•7969fad9d ago
auth blocker dialog + provider
baepaul•c0126a410d ago
making unauth views work, but introduces flicker for answer form on questions...
baepaul•38d9d4b10d ago
more nits
baepaul•8b52f5810d ago
refactoring create repo to use action
baepaul•4dd2a3c10d ago
bug fix - adding refresh for create question
baepaul•504974111d ago
typing actions properly ..
baepaul•cb221e811d ago
refactoring code / actions to use discriminated unions
baepaul•044783211d ago
only show dropdowns as applicable, user author matching and all that
baepaul•edc4fba11d ago
wiring up comment editing
baepaul•903ddcc11d ago
refactoring edit question dialog into separate components, no optimistic
baepaul•856d2c011d ago
wip question editing
baepaul•9279e5c11d ago
using refresh() and explaining why.
baepaul•dcace2a11d ago
refactoring to use action.bind everywhere
baepaul•3d3194811d ago
cleaning up vote code to use action.bind()
baepaul•4bb3c7711d ago
comment upvote wire up
baepaul•69c7a1e11d ago
wiring up vote boxes
baepaul•d2615aa11d ago
wiring up comments
baepaul•446b24011d ago
wiring up answer form and action
baepaul•a013ab612d ago