Skip to content

Commit

Permalink
feat: finish user management
Browse files Browse the repository at this point in the history
  • Loading branch information
tschoffelen committed Oct 6, 2024
1 parent 917c76e commit dda3513
Show file tree
Hide file tree
Showing 13 changed files with 778 additions and 18 deletions.
1 change: 1 addition & 0 deletions packages/api/src/routes/auth/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ app.post("/login", async (c) => {
await put(
{
pk: `access-token#${id}`,
type: 'access-token',
sk: user.pk,
accessTokenType: "dashboard",
},
Expand Down
122 changes: 116 additions & 6 deletions packages/api/src/routes/users/index.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
import { Hono } from "hono";
import { queryAll } from "../../lib/database";
import bcrypt from "bcryptjs";

import { deleteItem, put, query, queryAll, update } from "../../lib/database";

const app = new Hono();

app.get("/", async (c) => {
const items = await queryAll({
const getUser = async (username) => {
const { Items } = await query({
KeyConditionExpression: "pk = :pk",
ExpressionAttributeValues: {
":pk": `user#${username}`,
},
});

return Items?.[0];
};

const getAllUsers = async () =>
queryAll({
KeyConditionExpression: "#type = :type",
ExpressionAttributeNames: {
"#type": "type",
Expand All @@ -15,6 +28,23 @@ app.get("/", async (c) => {
IndexName: "type-sk",
});

const getAllAccessTokens = (username) =>
queryAll({
KeyConditionExpression: "#type = :type AND #sk = :sk",
ExpressionAttributeNames: {
"#type": "type",
"#sk": "sk",
},
ExpressionAttributeValues: {
":type": "access-token",
":sk": `user#${username}`,
},
IndexName: "type-sk",
});

app.get("/", async (c) => {
const items = await getAllUsers();

return c.json(
items.map((item) => ({
...item,
Expand All @@ -25,15 +55,95 @@ app.get("/", async (c) => {
});

app.post("/", async (c) => {
return c.json({ message: "Hello, World!" });
const body = await c.req.json();

if (!body.username) {
return c.json({ error: "Username is required" }, 400);
}
if (!body.password) {
return c.json({ error: "Password is required" }, 400);
}
if (body.password.length < 5) {
return c.json({ error: "Password must be at least 5 characters" }, 400);
}

const existingUser = await getUser(body.username);
if (existingUser) {
return c.json({ error: "A user with this username already exists" }, 400);
}

await put({
pk: `user#${body.username}`,
sk: "user",
type: "user",
name: body.username,
passwordHash: bcrypt.hashSync(body.password),
});

return c.json({ ok: true }, 201);
});

app.post("/:userId", async (c) => {
return c.json({ message: "Hello, World!" });
const body = await c.req.json();

const existingUser = await getUser(body.username);
if (!existingUser) {
return c.json({ error: "User not found" }, 400);
}

if (!body.password) {
return c.json({ error: "Password is required" }, 400);
}
if (body.password.length < 5) {
return c.json({ error: "Password must be at least 5 characters" }, 400);
}

await update({
Key: {
pk: existingUser.pk,
sk: existingUser.sk,
},
UpdateExpression: "SET #passwordHash = :passwordHash",
ExpressionAttributeValues: {
":passwordHash": bcrypt.hashSync(body.password),
},
ExpressionAttributeNames: {
"#passwordHash": "passwordHash",
},
});

return c.json({ ok: true });
});

app.delete("/:userId", async (c) => {
return c.json({ message: "Hello, World!" });
const existingUser = await getUser(c.req.param("userId"));
if (!existingUser) {
return c.json({ error: "This user does not exist" }, 404);
}

const allUsers = await getAllUsers();
if (allUsers.length < 2) {
return c.json({ error: "You cannot delete the last user" }, 400);
}

// Delete user
await deleteItem({
pk: existingUser.pk,
sk: existingUser.sk,
});

// Delete their access tokens
const accessTokens = await getAllAccessTokens(existingUser.name);
console.log("accessTokens", accessTokens);

for (const token of accessTokens) {
await deleteItem({
pk: token.pk,
sk: token.sk,
});
}

return c.json({ ok: true });
});

export default app;
1 change: 1 addition & 0 deletions packages/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.1.2",
"@tanstack/react-table": "^8.20.5",
"@xyflow/react": "^12.3.0",
Expand Down
96 changes: 96 additions & 0 deletions packages/dashboard/src/components/dialogs/create-user.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { useState } from "react";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";

import { useToast } from "@/hooks/use-toast";
import { authenticatedFetch } from "@/lib/api";

export function CreateUser({ mutate }) {
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const { toast } = useToast();

const submit = async (event) => {
event.preventDefault();

const username = event.target.querySelector("#username").value;
const password = event.target.querySelector("#password").value;

setLoading(true);
try {
await authenticatedFetch("/users", {
method: "POST",
body: JSON.stringify({ username, password }),
});

await mutate();

toast({ description: "User created" });
setOpen(false);
} catch (error: any) {
toast({
title: "An error occurred",
description: error.message,
variant: "destructive",
});
}
setLoading(false);
};

return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">Add user</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<form onSubmit={submit}>
<DialogHeader>
<DialogTitle>Create user</DialogTitle>
<DialogDescription>Add a new user to your team.</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="username" className="text-right">
Username
</Label>
<Input
id="username"
required
placeholder="pedro"
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="password" className="text-right">
Password
</Label>
<Input
id="password"
required
minLength={5}
type="password"
className="col-span-3"
/>
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={loading}>
Save changes
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
76 changes: 76 additions & 0 deletions packages/dashboard/src/components/dialogs/delete-user.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { useState } from "react";
import { TrashIcon } from "lucide-react";

import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";

import { useToast } from "@/hooks/use-toast";
import { authenticatedFetch, useData } from "@/lib/api";

export function DeleteUser({ id }) {
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);

const { mutate } = useData(`../users`);
const { toast } = useToast();

const submit = async (event) => {
event.preventDefault();

setLoading(true);
try {
await authenticatedFetch(`/users/${id}`, {
method: "DELETE",
});

await mutate();
setOpen(false);

toast({ description: "User deleted" });
} catch (error: any) {
toast({
title: "An error occurred",
description: error.message,
variant: "destructive",
});
}
setLoading(false);
};

return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<div className="flex justify-end">
<Button variant="outline" size="sm">
<TrashIcon className="size-4 mr-2" />
Remove
</Button>
</div>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<form onSubmit={submit} className="flex flex-col gap-4">
<DialogHeader>
<DialogTitle>Are you absolutely sure?</DialogTitle>
<DialogDescription>
Are you sure you want to remove this user? This action can't be
undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="submit" variant="destructive" disabled={loading}>
Remove user
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
Loading

0 comments on commit dda3513

Please sign in to comment.