wip
This commit is contained in:
parent
79c7edb30f
commit
3140da7bab
30 changed files with 1148 additions and 95 deletions
|
|
@ -4,6 +4,7 @@ import './chat/chat.ts'
|
|||
import './login/login.ts'
|
||||
import './signin/signin.ts'
|
||||
import './ttt/ttt.ts'
|
||||
import './profile/profile.ts'
|
||||
|
||||
// ---- Initial load ----
|
||||
setTitle("");
|
||||
|
|
|
|||
|
|
@ -5,14 +5,98 @@ import {
|
|||
type RouteHandlerParams,
|
||||
type RouteHandlerReturn,
|
||||
} from "@app/routing";
|
||||
import { showError, showInfo, showSuccess } from "@app/toast";
|
||||
import Cookie from "js-cookie";
|
||||
import authHtml from "./login.html?raw";
|
||||
import client from "@app/api";
|
||||
import { updateUser } from "@app/auth";
|
||||
import Cookie from "js-cookie";
|
||||
import loggedInHtml from "./alreadyLoggedin.html?raw";
|
||||
import cuteCat from "./cuteCat.png";
|
||||
import loggedInHtml from "./alreadyLoggedin.html?raw";
|
||||
import totpHtml from "./totp.html?raw";
|
||||
import { isNullish } from "@app/utils";
|
||||
import { showError, showInfo, showSuccess } from "@app/toast";
|
||||
import { updateUser } from "@app/auth";
|
||||
|
||||
const TOTP_LENGTH = 6;
|
||||
|
||||
async function handleOtp(app: HTMLElement, token: string, returnTo: string | null) {
|
||||
app.innerHTML = totpHtml;
|
||||
|
||||
const container = app.querySelector("#totp-container")!;
|
||||
container.innerHTML = "";
|
||||
|
||||
const inputs: HTMLInputElement[] = [];
|
||||
|
||||
for (let i = 0; i < TOTP_LENGTH; i++) {
|
||||
const input = document.createElement("input");
|
||||
input.maxLength = 1;
|
||||
input.inputMode = "numeric";
|
||||
input.className =
|
||||
"w-12 h-12 text-center text-xl border border-gray-300 rounded " +
|
||||
"focus:outline-none focus:ring-2 focus:ring-blue-500";
|
||||
|
||||
container.appendChild(input);
|
||||
inputs.push(input);
|
||||
|
||||
// Handle typing a digit
|
||||
input.addEventListener("input", async () => {
|
||||
const value = input.value.replace(/\D/g, "");
|
||||
input.value = value;
|
||||
|
||||
// Auto-advance when filled
|
||||
if (value && i < TOTP_LENGTH - 1) {
|
||||
inputs[i + 1].focus();
|
||||
}
|
||||
await checkComplete();
|
||||
});
|
||||
|
||||
// Handle backspace
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Backspace" && !input.value && i > 0) {
|
||||
inputs[i - 1].focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle pasting a full code
|
||||
input.addEventListener("paste", (e: ClipboardEvent) => {
|
||||
const pasted = e.clipboardData?.getData("text") ?? "";
|
||||
const digits = pasted.replace(/\D/g, "").slice(0, TOTP_LENGTH);
|
||||
|
||||
if (digits.length > 1) {
|
||||
e.preventDefault();
|
||||
digits.split("").forEach((d, idx) => {
|
||||
if (inputs[idx]) inputs[idx].value = d;
|
||||
});
|
||||
if (digits.length === TOTP_LENGTH) checkComplete();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Check if all digits are entered and then call totpSend
|
||||
async function checkComplete() {
|
||||
const code = inputs.map((i) => i.value).join("");
|
||||
if (code.length === TOTP_LENGTH && /^[0-9]+$/.test(code)) {
|
||||
let res = await client.loginOtp({
|
||||
loginOtpRequest: {
|
||||
code, token,
|
||||
}
|
||||
})
|
||||
|
||||
if (res.kind === "success") {
|
||||
Cookie.set("token", res.payload.token, {
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
});
|
||||
if (returnTo !== null) navigateTo(returnTo);
|
||||
else navigateTo("/");
|
||||
}
|
||||
else if (res.kind === "failed") {
|
||||
showError(`Failed to authenticate: ${res.msg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inputs[0].focus();
|
||||
}
|
||||
|
||||
|
||||
async function handleLogin(
|
||||
_url: string,
|
||||
|
|
@ -67,7 +151,7 @@ async function handleLogin(
|
|||
return showError(
|
||||
"Error while rendering the page: no form found",
|
||||
);
|
||||
fLogin.addEventListener("submit", async function (e: SubmitEvent) {
|
||||
fLogin.addEventListener("submit", async function(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
let form = e.target as HTMLFormElement | null;
|
||||
if (form === null) return showError("Failed to send form...");
|
||||
|
|
@ -109,8 +193,7 @@ async function handleLogin(
|
|||
break;
|
||||
}
|
||||
case "otpRequired": {
|
||||
showInfo("Got ask OTP, not yet implemented");
|
||||
break;
|
||||
return await handleOtp(app!, res.payload.token, returnTo);
|
||||
}
|
||||
case "failed": {
|
||||
showError(`Failed to login: ${res.msg}`);
|
||||
|
|
|
|||
11
frontend/src/pages/login/totp.html
Normal file
11
frontend/src/pages/login/totp.html
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<div class="grid h-full place-items-center">
|
||||
<div class="bg-white shadow-lg rounded-2xl p-8 w-full max-w-md">
|
||||
<h1 class="text-2xl font-semibold text-center mb-6 text-gray-800">
|
||||
Welcome to <span>ft boules</span>
|
||||
</h1>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Code</label>
|
||||
<div id="totp-container" class="flex gap-2 justify-center"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
66
frontend/src/pages/profile/profile.html
Normal file
66
frontend/src/pages/profile/profile.html
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<div class="grid h-full place-items-center">
|
||||
<div class="bg-white shadow-lg rounded-2xl p-8 w-full max-w-md">
|
||||
<h1 class="text-2xl font-semibold mb-6 text-gray-700">Edit Profile</h1>
|
||||
|
||||
<div id="isGuestBox" class="border-red-600 rounded-2xl border-2" hidden>
|
||||
<h2 class="text-2xl font-semibold text-red-600"> This is a guest Account</h2>
|
||||
<span class="text-red-600"> You can't change anything here </span>
|
||||
</div>
|
||||
|
||||
<!-- Login Name -->
|
||||
<div id="loginNameWrapper" class="py-2">
|
||||
<label class="block font-medium mb-1 text-gray-700">Login Name</label>
|
||||
<div id="loginNameBox" class="font-medium mb-1 text-gray-700 rounded-sm border-2 outline-lime-100"></div>
|
||||
</div>
|
||||
|
||||
<!-- Display Name -->
|
||||
<div id="displayNameWrapper" class="py-2">
|
||||
<label class="block font-medium mb-1 text-gray-700">Display Name</label>
|
||||
<input id="displayNameBox" type="text" placeholder="Display Name" name="DisplayName"
|
||||
class="w-full px-4 py-2 border border-gray-300 text-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
<button id="displayNameButton" class="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700">
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Password -->
|
||||
<div id="passwordWrapper" class="py-2">
|
||||
<label class="block font-medium mb-1 text-gray-700">Change Password</label>
|
||||
<input id="passwordBox" type="password" placeholder="New Password" name="Password"
|
||||
class="w-full px-4 py-2 border border-gray-300 text-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
<button id="passwordButton" class="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700">
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- TOTP -->
|
||||
<div class="border rounded p-4">
|
||||
<h2 class="font-semibold text-lg mb-2">Two-Factor Authentication (TOTP)</h2>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<span id="totpStatusText" class="font-medium text-gray-700">Status: Disabled</span>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button id="enableTotp" type="button"
|
||||
class="bg-green-600 text-white-700 px-3 py-1 rounded hover:bg-green-700">
|
||||
Enable
|
||||
</button>
|
||||
|
||||
<button id="disableTotp" type="button"
|
||||
class="bg-red-600 text-white-700 px-3 py-1 rounded hover:bg-red-700 hidden">
|
||||
Disable
|
||||
</button>
|
||||
|
||||
<button id="showSecret" type="button"
|
||||
class="bg-blue-600 text-white-700 px-3 py-1 rounded hover:bg-blue-700 hidden">
|
||||
Show Secret
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p id="totpSecretBox" class="mt-3 text-sm bg-gray-100 border p-2 rounded hidden"></p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
159
frontend/src/pages/profile/profile.ts
Normal file
159
frontend/src/pages/profile/profile.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { addRoute, navigateTo, setTitle } from "@app/routing";
|
||||
import { showError } from "@app/toast";
|
||||
import page from './profile.html?raw'
|
||||
import { updateUser } from "@app/auth";
|
||||
import { isNullish } from "@app/utils";
|
||||
import client from "@app/api";
|
||||
|
||||
|
||||
async function route(url: string, _args: { [k: string]: string }) {
|
||||
setTitle('Edit Profile')
|
||||
return {
|
||||
html: page, postInsert: async (app: HTMLElement | undefined) => {
|
||||
const user = await updateUser();
|
||||
if (isNullish(user))
|
||||
return showError('No User');
|
||||
if (isNullish(app))
|
||||
return showError('Failed to render');
|
||||
let totpState = await (async () => {
|
||||
let res = await client.statusOtp();
|
||||
if (res.kind === "success")
|
||||
return {
|
||||
enabled: (res.msg as string) === "statusOtp.success.enabled",
|
||||
secret: ((res.msg as string) === "statusOtp.success.enabled") ? res.payload.secret : null,
|
||||
};
|
||||
else {
|
||||
showError('Failed to get OTP status')
|
||||
return {
|
||||
enabled: false, secret: null,
|
||||
}
|
||||
}
|
||||
|
||||
})()
|
||||
// ---- Simulated State ----
|
||||
let totpEnabled = totpState.enabled;
|
||||
let totpSecret = totpState.secret; // would come from backend
|
||||
|
||||
let guestBox = app.querySelector<HTMLDivElement>("#isGuestBox")!;
|
||||
let displayNameWrapper = app.querySelector<HTMLDivElement>("#displayNameWrapper")!;
|
||||
let displayNameBox = app.querySelector<HTMLInputElement>("#displayNameBox")!;
|
||||
let displayNameButton = app.querySelector<HTMLButtonElement>("#displayNameButton")!;
|
||||
let loginNameWrapper = app.querySelector<HTMLDivElement>("#loginNameWrapper")!;
|
||||
let loginNameBox = app.querySelector<HTMLDivElement>("#loginNameBox")!;
|
||||
let passwordWrapper = app.querySelector<HTMLDivElement>("#passwordWrapper")!;
|
||||
let passwordBox = app.querySelector<HTMLInputElement>("#passwordBox")!;
|
||||
let passwordButton = app.querySelector<HTMLButtonElement>("#passwordButton")!;
|
||||
|
||||
|
||||
if (!isNullish(user.selfInfo?.loginName))
|
||||
loginNameBox.innerText = user.selfInfo?.loginName;
|
||||
else
|
||||
loginNameBox.innerHTML = '<span class="text-red-600 font-bold mb-1">You don\'t have a login name</span>';
|
||||
displayNameBox.value = user.name;
|
||||
|
||||
guestBox.hidden = !user.guest;
|
||||
|
||||
// ---- DOM Elements ----
|
||||
const totpStatusText = app.querySelector("#totpStatusText")!;
|
||||
const enableBtn = app.querySelector<HTMLButtonElement>("#enableTotp")!;
|
||||
const disableBtn = app.querySelector<HTMLButtonElement>("#disableTotp")!;
|
||||
const showSecretBtn = app.querySelector<HTMLButtonElement>("#showSecret")!;
|
||||
const secretBox = app.querySelector("#totpSecretBox")!;
|
||||
|
||||
if (user.guest) {
|
||||
for (let c of passwordButton.classList.values()) {
|
||||
if (c.startsWith('bg-') || c.startsWith('hover:bg-'))
|
||||
passwordButton.classList.remove(c);
|
||||
}
|
||||
passwordButton.disabled = true;
|
||||
passwordButton.classList.add('bg-gray-700', 'hover:bg-gray-700');
|
||||
|
||||
passwordBox.disabled = true;
|
||||
passwordBox.classList.add('color-white');
|
||||
|
||||
for (let c of displayNameButton.classList.values()) {
|
||||
if (c.startsWith('bg-') || c.startsWith('hover:bg-'))
|
||||
displayNameButton.classList.remove(c);
|
||||
}
|
||||
displayNameButton.disabled = true;
|
||||
displayNameButton.classList.add('bg-gray-700');
|
||||
displayNameButton.classList.add('color-white');
|
||||
|
||||
displayNameBox.disabled = true;
|
||||
displayNameBox.classList.add('color-white');
|
||||
|
||||
for (let c of enableBtn.classList.values()) {
|
||||
if (c.startsWith('bg-') || c.startsWith('hover:bg-'))
|
||||
enableBtn.classList.remove(c);
|
||||
}
|
||||
for (let c of disableBtn.classList.values()) {
|
||||
if (c.startsWith('bg-') || c.startsWith('hover:bg-'))
|
||||
disableBtn.classList.remove(c);
|
||||
}
|
||||
for (let c of showSecretBtn.classList.values()) {
|
||||
if (c.startsWith('bg-') || c.startsWith('hover:bg-'))
|
||||
showSecretBtn.classList.remove(c);
|
||||
}
|
||||
enableBtn.classList.add('bg-gray-700', 'hover:bg-gray-700');
|
||||
disableBtn.classList.add('bg-gray-700', 'hover:bg-gray-700');
|
||||
showSecretBtn.classList.add('bg-gray-700', 'hover:bg-gray-700');
|
||||
|
||||
enableBtn.disabled = true;
|
||||
disableBtn.disabled = true;
|
||||
showSecretBtn.disabled = true;
|
||||
}
|
||||
|
||||
|
||||
// ---- Update UI ----
|
||||
function refreshTotpUI() {
|
||||
if (totpEnabled) {
|
||||
totpStatusText.textContent = "Status: Enabled";
|
||||
|
||||
enableBtn.classList.add("hidden");
|
||||
disableBtn.classList.remove("hidden");
|
||||
showSecretBtn.classList.remove("hidden");
|
||||
} else {
|
||||
totpStatusText.textContent = "Status: Disabled";
|
||||
|
||||
enableBtn.classList.remove("hidden");
|
||||
disableBtn.classList.add("hidden");
|
||||
showSecretBtn.classList.add("hidden");
|
||||
secretBox.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Button Events ----
|
||||
enableBtn.onclick = async () => {
|
||||
let res = await client.enableOtp();
|
||||
if (res.kind === "success") {
|
||||
navigateTo(url);
|
||||
}
|
||||
else {
|
||||
showError(`failed to activate OTP: ${res.msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
disableBtn.onclick = async () => {
|
||||
let res = await client.disableOtp();
|
||||
if (res.kind === "success") {
|
||||
navigateTo(url);
|
||||
}
|
||||
else {
|
||||
showError(`failed to deactivate OTP: ${res.msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
showSecretBtn.onclick = () => {
|
||||
secretBox.textContent = `TOTP Secret: ${totpSecret}`;
|
||||
secretBox.classList.toggle("hidden");
|
||||
};
|
||||
|
||||
// Initialize UI state
|
||||
refreshTotpUI();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
addRoute('/profile', route)
|
||||
Loading…
Add table
Add a link
Reference in a new issue