feat: added auth and crazy more things
This commit is contained in:
1 parent
1ecf468140
commit
be9d6b610f
10 files changed
+1500
-117
No files matched your search
+580
-4
@@ -1,12 +1,168 @@
|
||||
import m from "https://esm.sh/mithril@2.3.8";
|
||||
|
||||
const API = "/api/v1/flashcards";
|
||||
const AUTH = "/api/v1/auth";
|
||||
const USERS = "/api/v1/users";
|
||||
const SESSIONS = "/api/v1/sessions";
|
||||
|
||||
let user = null;
|
||||
let authLoading = true;
|
||||
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const res = await fetch(AUTH + "/me");
|
||||
user = res.ok ? await res.json() : null;
|
||||
} catch {
|
||||
user = null;
|
||||
}
|
||||
authLoading = false;
|
||||
}
|
||||
|
||||
const Login = {
|
||||
view: function (v) {
|
||||
if (authLoading) {
|
||||
return m(".cyber-center-xy", [
|
||||
m(".cyber-spinner.cyber-spinner--lg"),
|
||||
m("p.cyber-text-muted.cyber-mt-md", "loading..."),
|
||||
]);
|
||||
}
|
||||
if (user) return null;
|
||||
|
||||
const err = v.state.error;
|
||||
return m(".cyber-center-xy", [
|
||||
m(
|
||||
".cyber-card.cyber-card--holo",
|
||||
{ style: "width:360px;max-width:90vw" },
|
||||
[
|
||||
m(".cyber-card__header", [
|
||||
m("h2.cyber-text-cyan", "St-Pierre"),
|
||||
m("p.cyber-text-muted", "Login"),
|
||||
]),
|
||||
m(".cyber-card__body", [
|
||||
err
|
||||
? m(".cyber-alert.cyber-alert--danger.cyber-mb-md", err)
|
||||
: null,
|
||||
m(
|
||||
"form",
|
||||
{
|
||||
onsubmit: async function (e) {
|
||||
e.preventDefault();
|
||||
v.state.error = null;
|
||||
const fd = new FormData(e.target);
|
||||
const res = await fetch(AUTH + "/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username: fd.get("username"),
|
||||
password: fd.get("password"),
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
await checkAuth();
|
||||
m.redraw();
|
||||
return;
|
||||
}
|
||||
const d = await res.json();
|
||||
v.state.error = d.error || "Login failed";
|
||||
m.redraw();
|
||||
},
|
||||
},
|
||||
[
|
||||
m(".cyber-field.cyber-mb-sm", [
|
||||
m(
|
||||
"input.cyber-input",
|
||||
{
|
||||
name: "username",
|
||||
placeholder: "Username",
|
||||
autofocus: true,
|
||||
required: true,
|
||||
},
|
||||
),
|
||||
]),
|
||||
m(".cyber-field.cyber-mb-md", [
|
||||
m("input.cyber-input", {
|
||||
name: "password",
|
||||
type: "password",
|
||||
placeholder: "Password",
|
||||
required: true,
|
||||
}),
|
||||
]),
|
||||
m(
|
||||
"button.cyber-btn.cyber-btn--primary.cyber-btn--block",
|
||||
{ type: "submit" },
|
||||
"Login",
|
||||
),
|
||||
],
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
const Layout = {
|
||||
oninit: async function () {
|
||||
if (user === null) {
|
||||
await checkAuth();
|
||||
m.redraw();
|
||||
}
|
||||
},
|
||||
view: function (v) {
|
||||
if (authLoading) {
|
||||
return m(".cyber-center-xy", [
|
||||
m(".cyber-spinner.cyber-spinner--lg"),
|
||||
m("p.cyber-text-muted.cyber-mt-md", "loading..."),
|
||||
]);
|
||||
}
|
||||
if (!user) return m(Login);
|
||||
|
||||
return m("div", [
|
||||
m(
|
||||
"header.cyber-flex.cyber-justify-between.cyber-items-center.cyber-p-md",
|
||||
{ style: "border-bottom:1px solid var(--cyber-void-700)" },
|
||||
[
|
||||
m(
|
||||
"button.cyber-btn.cyber-btn--ghost.cyber-font-mono",
|
||||
{ onclick: () => m.route.set("/") },
|
||||
"St-Pierre",
|
||||
),
|
||||
m("div.cyber-flex.cyber-items-center.cyber-gap-sm", [
|
||||
m(
|
||||
"button.cyber-btn.cyber-btn--ghost.cyber-btn--sm",
|
||||
{ onclick: () => m.route.set("/tokens") },
|
||||
"Tokens",
|
||||
),
|
||||
m("span.cyber-badge.cyber-badge--pulse", user.username),
|
||||
m(
|
||||
"button.cyber-btn.cyber-btn--ghost.cyber-btn--sm",
|
||||
{ onclick: () => m.route.set("/profile") },
|
||||
"Profile",
|
||||
),
|
||||
m(
|
||||
"button.cyber-btn.cyber-btn--ghost.cyber-btn--sm",
|
||||
{
|
||||
onclick: async () => {
|
||||
await fetch(AUTH + "/logout", { method: "POST" });
|
||||
user = null;
|
||||
m.redraw();
|
||||
},
|
||||
},
|
||||
"Logout",
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
m("main", v.children),
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
const Card = {
|
||||
view: function (v) {
|
||||
const c = v.attrs.card;
|
||||
const maxNote =
|
||||
(c.note || "").length > 120 ? c.note.slice(0, 120) + "…" : c.note;
|
||||
(c.note || "").length > 120 ? c.note.slice(0, 120) + "\u2026" : c.note;
|
||||
return m(
|
||||
".cyber-card.cyber-card--interactive.cyber-neon-border.cyber-mb-md",
|
||||
{
|
||||
@@ -76,7 +232,7 @@ const List = {
|
||||
if (v.state.loading) {
|
||||
return m(".cyber-center-xy", [
|
||||
m(".cyber-spinner.cyber-spinner--lg"),
|
||||
m("p.cyber-text-muted.cyber-mt-md", "loading…"),
|
||||
m("p.cyber-text-muted.cyber-mt-md", "loading\u2026"),
|
||||
]);
|
||||
}
|
||||
const totalPages = Math.max(1, Math.ceil(v.state.cards.length / PAGE_SIZE));
|
||||
@@ -195,7 +351,427 @@ const Detail = {
|
||||
},
|
||||
};
|
||||
|
||||
const Users = {
|
||||
oninit: async function (v) {
|
||||
v.state.users = [];
|
||||
v.state.loading = true;
|
||||
try {
|
||||
const res = await fetch(USERS);
|
||||
const data = await res.json();
|
||||
v.state.users = data.users || [];
|
||||
} catch {}
|
||||
v.state.loading = false;
|
||||
m.redraw();
|
||||
},
|
||||
view: function (v) {
|
||||
if (v.state.loading) {
|
||||
return m(".cyber-center-xy", [
|
||||
m(".cyber-spinner.cyber-spinner--lg"),
|
||||
m("p.cyber-text-muted.cyber-mt-md", "loading\u2026"),
|
||||
]);
|
||||
}
|
||||
|
||||
const err = v.state.error;
|
||||
const ok = v.state.ok;
|
||||
|
||||
return m(".cyber-p-lg", { style: "max-width:640px;margin:0 auto" }, [
|
||||
m("h2.cyber-text-cyan.cyber-mb-md", "Users"),
|
||||
ok
|
||||
? m(".cyber-alert.cyber-alert--success.cyber-mb-md", ok)
|
||||
: null,
|
||||
err
|
||||
? m(".cyber-alert.cyber-alert--danger.cyber-mb-md", err)
|
||||
: null,
|
||||
m(
|
||||
".cyber-card.cyber-card--holo.cyber-mb-lg",
|
||||
[
|
||||
m(".cyber-card__header", m("h3", "Create User")),
|
||||
m(".cyber-card__body", [
|
||||
m(
|
||||
"form",
|
||||
{
|
||||
onsubmit: async function (e) {
|
||||
e.preventDefault();
|
||||
v.state.error = null;
|
||||
v.state.ok = null;
|
||||
const fd = new FormData(e.target);
|
||||
const res = await fetch(USERS, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username: fd.get("username"),
|
||||
password: fd.get("password"),
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
e.target.reset();
|
||||
v.state.ok = `User ${fd.get("username")} created.`;
|
||||
const data = await fetch(USERS).then((r) => r.json());
|
||||
v.state.users = data.users || [];
|
||||
} else {
|
||||
const d = await res.json();
|
||||
v.state.error = d.error || "Failed to create user";
|
||||
}
|
||||
m.redraw();
|
||||
},
|
||||
},
|
||||
[
|
||||
m(".cyber-flex.cyber-gap-md.cyber-mb-sm", [
|
||||
m("input.cyber-input", {
|
||||
name: "username",
|
||||
placeholder: "Username",
|
||||
required: true,
|
||||
style: "flex:1",
|
||||
}),
|
||||
m("input.cyber-input", {
|
||||
name: "password",
|
||||
type: "password",
|
||||
placeholder: "Password (min 8 chars)",
|
||||
required: true,
|
||||
minlength: 8,
|
||||
style: "flex:1",
|
||||
}),
|
||||
m(
|
||||
"button.cyber-btn.cyber-btn--primary",
|
||||
{ type: "submit" },
|
||||
"Create",
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
v.state.users.length === 0
|
||||
? m("p.cyber-text-muted", "No users found.")
|
||||
: m(
|
||||
".cyber-card.cyber-card--holo",
|
||||
[
|
||||
m(".cyber-card__header", m("h3", `All Users (${v.state.users.length})`)),
|
||||
m(".cyber-card__body", [
|
||||
m(
|
||||
"table.cyber-table",
|
||||
{ style: "width:100%" },
|
||||
[
|
||||
m("thead", m("tr", [
|
||||
m("th", "ID"),
|
||||
m("th", "Username"),
|
||||
m("th", "Created"),
|
||||
m("th", "Active"),
|
||||
])),
|
||||
m(
|
||||
"tbody",
|
||||
v.state.users.map((u) =>
|
||||
m("tr", [
|
||||
m("td.cyber-font-mono", u.id),
|
||||
m("td", u.username),
|
||||
m("td.cyber-font-mono", u.created_at),
|
||||
m("td", u.active ? m("span.cyber-badge.cyber-badge--pulse", "yes") : m("span.cyber-badge", "no")),
|
||||
]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
const Profile = {
|
||||
view: function (v) {
|
||||
const err = v.state.error;
|
||||
const ok = v.state.ok;
|
||||
|
||||
return m(".cyber-p-lg", { style: "max-width:640px;margin:0 auto" }, [
|
||||
m("h2.cyber-text-cyan.cyber-mb-md", "Profile"),
|
||||
m(
|
||||
".cyber-card.cyber-card--holo.cyber-mb-lg",
|
||||
[
|
||||
m(".cyber-card__header", m("h3", user.username)),
|
||||
m(".cyber-card__body", [
|
||||
m(".cyber-field.cyber-mb-sm", [
|
||||
m(".cyber-field__label", "User ID"),
|
||||
m("p.cyber-font-mono", user.id),
|
||||
]),
|
||||
m(".cyber-field", [
|
||||
m(".cyber-field__label", "Created"),
|
||||
m("p.cyber-font-mono", user.created_at),
|
||||
]),
|
||||
]),
|
||||
],
|
||||
),
|
||||
m(
|
||||
".cyber-card.cyber-card--holo",
|
||||
[
|
||||
m(".cyber-card__header", m("h3", "Change Password")),
|
||||
m(".cyber-card__body", [
|
||||
ok
|
||||
? m(".cyber-alert.cyber-alert--success.cyber-mb-md", ok)
|
||||
: null,
|
||||
err
|
||||
? m(".cyber-alert.cyber-alert--danger.cyber-mb-md", err)
|
||||
: null,
|
||||
m(
|
||||
"form",
|
||||
{
|
||||
onsubmit: async function (e) {
|
||||
e.preventDefault();
|
||||
v.state.error = null;
|
||||
v.state.ok = null;
|
||||
const fd = new FormData(e.target);
|
||||
const res = await fetch(AUTH + "/password", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
old_password: fd.get("old_password"),
|
||||
new_password: fd.get("new_password"),
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
e.target.reset();
|
||||
v.state.ok = "Password changed.";
|
||||
} else {
|
||||
const d = await res.json();
|
||||
v.state.error = d.error || "Failed to change password";
|
||||
}
|
||||
m.redraw();
|
||||
},
|
||||
},
|
||||
[
|
||||
m(".cyber-field.cyber-mb-sm", [
|
||||
m("input.cyber-input", {
|
||||
name: "old_password",
|
||||
type: "password",
|
||||
placeholder: "Current password",
|
||||
required: true,
|
||||
autofocus: true,
|
||||
}),
|
||||
]),
|
||||
m(".cyber-field.cyber-mb-md", [
|
||||
m("input.cyber-input", {
|
||||
name: "new_password",
|
||||
type: "password",
|
||||
placeholder: "New password (min 8 chars)",
|
||||
required: true,
|
||||
minlength: 8,
|
||||
}),
|
||||
]),
|
||||
m(
|
||||
"button.cyber-btn.cyber-btn--primary.cyber-btn--block",
|
||||
{ type: "submit" },
|
||||
"Change Password",
|
||||
),
|
||||
],
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
const Tokens = {
|
||||
oninit: async function (v) {
|
||||
v.state.sessions = [];
|
||||
v.state.loading = true;
|
||||
try {
|
||||
const res = await fetch(SESSIONS);
|
||||
const data = await res.json();
|
||||
v.state.sessions = data.sessions || [];
|
||||
} catch {}
|
||||
v.state.loading = false;
|
||||
m.redraw();
|
||||
},
|
||||
view: function (v) {
|
||||
if (v.state.loading) {
|
||||
return m(".cyber-center-xy", [
|
||||
m(".cyber-spinner.cyber-spinner--lg"),
|
||||
m("p.cyber-text-muted.cyber-mt-md", "loading\u2026"),
|
||||
]);
|
||||
}
|
||||
|
||||
const err = v.state.error;
|
||||
const newToken = v.state.newToken;
|
||||
|
||||
return m(".cyber-p-lg", { style: "max-width:720px;margin:0 auto" }, [
|
||||
m("h2.cyber-text-cyan.cyber-mb-md", "API Tokens"),
|
||||
|
||||
newToken
|
||||
? m(
|
||||
".cyber-card.cyber-card--holo.cyber-mb-lg",
|
||||
{ style: "border-color:var(--cyber-yellow-500)" },
|
||||
[
|
||||
m(".cyber-card__header", [
|
||||
m(
|
||||
"span.cyber-badge.cyber-badge--pulse.cyber-mr-sm",
|
||||
"NEW",
|
||||
),
|
||||
m("span.cyber-text-yellow", newToken.label),
|
||||
]),
|
||||
m(".cyber-card__body", [
|
||||
m("p.cyber-text-yellow.cyber-mb-sm", [
|
||||
m("strong", "Copy this token now. "),
|
||||
"It will never be shown again.",
|
||||
]),
|
||||
m(
|
||||
".cyber-field.cyber-mb-sm",
|
||||
m(
|
||||
".cyber-flex.cyber-gap-sm",
|
||||
[
|
||||
m("input.cyber-input.cyber-font-mono", {
|
||||
value: newToken.token,
|
||||
readonly: true,
|
||||
style: "flex:1;user-select:all",
|
||||
onfocus: function (e) {
|
||||
e.target.select();
|
||||
},
|
||||
}),
|
||||
m(
|
||||
"button.cyber-btn.cyber-btn--sm",
|
||||
{
|
||||
onclick: function () {
|
||||
const input = v.dom.querySelector(
|
||||
"input[readonly]",
|
||||
);
|
||||
navigator.clipboard.writeText(newToken.token);
|
||||
input.select();
|
||||
},
|
||||
},
|
||||
"Copy",
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
m(
|
||||
"button.cyber-btn.cyber-btn--ghost.cyber-btn--sm",
|
||||
{ onclick: () => { v.state.newToken = null; } },
|
||||
"Dismiss",
|
||||
),
|
||||
]),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
|
||||
err
|
||||
? m(".cyber-alert.cyber-alert--danger.cyber-mb-md", err)
|
||||
: null,
|
||||
|
||||
m(
|
||||
".cyber-card.cyber-card--holo.cyber-mb-lg",
|
||||
[
|
||||
m(".cyber-card__header", m("h3", "Create Token")),
|
||||
m(".cyber-card__body", [
|
||||
m(
|
||||
"form",
|
||||
{
|
||||
onsubmit: async function (e) {
|
||||
e.preventDefault();
|
||||
v.state.error = null;
|
||||
const fd = new FormData(e.target);
|
||||
const res = await fetch(SESSIONS, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ label: fd.get("label") }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const token = await res.json();
|
||||
e.target.reset();
|
||||
const data = await fetch(SESSIONS).then((r) => r.json());
|
||||
v.state.sessions = data.sessions || [];
|
||||
v.state.newToken = token;
|
||||
} else {
|
||||
const d = await res.json();
|
||||
v.state.error = d.error || "Failed to create token";
|
||||
}
|
||||
m.redraw();
|
||||
},
|
||||
},
|
||||
[
|
||||
m(".cyber-flex.cyber-gap-md.cyber-mb-sm", [
|
||||
m("input.cyber-input", {
|
||||
name: "label",
|
||||
placeholder: "e.g. opencode on shulgi",
|
||||
required: true,
|
||||
style: "flex:1",
|
||||
}),
|
||||
m(
|
||||
"button.cyber-btn.cyber-btn--primary",
|
||||
{ type: "submit" },
|
||||
"Generate",
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
|
||||
v.state.sessions.length === 0
|
||||
? m("p.cyber-text-muted", "No tokens yet.")
|
||||
: m(
|
||||
".cyber-card.cyber-card--holo",
|
||||
[
|
||||
m(".cyber-card__header", m("h3", `Your Tokens (${v.state.sessions.length})`)),
|
||||
m(".cyber-card__body", [
|
||||
m(
|
||||
"table.cyber-table",
|
||||
{ style: "width:100%" },
|
||||
[
|
||||
m("thead", m("tr", [
|
||||
m("th", "ID"),
|
||||
m("th", "Label"),
|
||||
m("th", "Created"),
|
||||
m("th", "Last Used"),
|
||||
m("th", "Status"),
|
||||
m("th"),
|
||||
])),
|
||||
m(
|
||||
"tbody",
|
||||
v.state.sessions.map((s) =>
|
||||
m("tr", [
|
||||
m("td.cyber-font-mono", s.id),
|
||||
m("td", s.label),
|
||||
m("td.cyber-font-mono", s.created_at),
|
||||
m("td.cyber-font-mono", s.last_used_at || "\u2014"),
|
||||
m("td", s.active
|
||||
? m("span.cyber-badge.cyber-badge--pulse", "active")
|
||||
: m("span.cyber-badge.cyber-badge--ghost", "revoked"),
|
||||
),
|
||||
m("td", s.active
|
||||
? m(
|
||||
"button.cyber-btn.cyber-btn--ghost.cyber-btn--sm.cyber-text-magenta",
|
||||
{
|
||||
onclick: async () => {
|
||||
await fetch(`${SESSIONS}/${s.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const data = await fetch(SESSIONS).then((r) => r.json());
|
||||
v.state.sessions = data.sessions || [];
|
||||
m.redraw();
|
||||
},
|
||||
},
|
||||
"Revoke",
|
||||
)
|
||||
: null,
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
m.route(document.getElementById("app"), "/", {
|
||||
"/": List,
|
||||
"/flashcard/:id": Detail,
|
||||
"/": { render: () => m(Layout, m(List)) },
|
||||
"/flashcard/:id": { render: (v) => m(Layout, m(Detail, { id: v.attrs.id })) },
|
||||
"/users": { render: () => m(Layout, m(Users)) },
|
||||
"/tokens": { render: () => m(Layout, m(Tokens)) },
|
||||
"/profile": { render: () => m(Layout, m(Profile)) },
|
||||
});
|
||||
Reference in new issue
Block a user