Flaxon 0.3.0 and Teloce-Py 0.3.0 are available on PyPI. Release instructions are maintained on this site; source links identify the framework snapshot. Check version and upgrade guidance.
09 | Login and project screens
Goal
Replace placeholders with working account forms and a project list.
What you are building
Login uses the shared API helper to load the anonymous session, submit register/login, and replace cached session state after rotation. Loading guards disable duplicate submissions and error messages remain visible. ProjectList requests only authorized records from the API and creates a project from a form. Internal project anchors use data-teloce-link. The server owns authorization; TypeScript types and disabled buttons do not enforce permissions.
Build this chapter
Stop the server. Work in project_manager. Follow each CREATE, whole-file replacement, or marked-block edit below in order.
Step 1: EDIT - replace the entire file - modules/auth/ui/pages/Login.html
Replace the placeholder with account forms. Use the session API and rotated CSRF token; do not read the HttpOnly cookie from JavaScript.
Select all existing contents of this file and replace them with the complete code below.
<template>
<section class="narrow">
<h1>Your account</h1>
<p v-if="busy" role="status">Please wait…</p>
<p v-if="message" role="alert">{{ message }}</p>
<div v-if="user">
<p>Signed in as {{ user.name }}.</p>
<a href="/projects" data-teloce-link>Open projects</a>
<button type="button" @click="logout" :disabled="busy">Sign out</button>
</div>
<form v-show="!user" @submit.prevent="submit">
<label v-show="registering"
>Name<input
name="name"
v-model="name"
maxlength="80"
:required="registering"
autocomplete="name"
/></label>
<label
>Email<input
name="email"
type="email"
v-model="email"
required
autocomplete="username"
/></label>
<label
>Password<input
name="password"
type="password"
v-model="password"
minlength="12"
maxlength="128"
required
autocomplete="current-password"
/></label>
<button :disabled="busy">{{ registering ? "Create account" : "Sign in" }}</button>
<button
type="button"
class="secondary"
@click="registering = !registering"
:disabled="busy"
>
{{ registering ? "Use an existing account" : "Register instead" }}
</button>
</form>
</section>
</template>
<script lang="ts">
import { api, loadSession } from "../../../../ui/api.ts";
import type { Session } from "../../../../ui/types.ts";
export default {
data() {
return {
user: null,
registering: false,
name: "",
email: "",
password: "",
busy: false,
message: "",
};
},
async mounted() {
try {
this.user = (await loadSession()).user;
} catch (error) {
this.message = error.message;
}
},
methods: {
async submit() {
if (this.busy) return;
this.busy = true;
this.message = "";
try {
const path = this.registering ? "/api/auth/register" : "/api/auth/login";
const result = await api<Session>(path, "POST", {
name: this.name,
email: this.email,
password: this.password,
});
this.user = result.user;
this.password = "";
window.__TELOCE_ROUTER__.navigate("/projects");
} catch (error) {
this.message = error.message;
} finally {
this.busy = false;
}
},
async logout() {
this.busy = true;
try {
await api("/api/auth/logout", "POST", {});
this.user = null;
this.message = "Signed out.";
} catch (error) {
this.message = error.message;
} finally {
this.busy = false;
}
},
},
};
</script>
<style scoped>
* {
box-sizing: border-box;
}
a {
color: #1d4ed8;
}
h1 {
font-size: clamp(1.8rem, 5vw, 3rem);
line-height: 1.15;
}
h2 {
font-size: 1.2rem;
}
p {
line-height: 1.6;
}
[role="alert"] {
color: #a21c2b;
padding: 0.8rem;
border-left: 4px solid;
}
:focus-visible {
outline: 3px solid #f59e0b;
outline-offset: 3px;
}
label {
display: grid;
gap: 0.4rem;
margin: 0.9rem 0;
font-weight: 600;
}
input,
select,
textarea,
button {
font: inherit;
border-radius: 7px;
padding: 0.7rem;
min-height: 44px;
}
input,
select,
textarea {
width: 100%;
border: 1px solid #abb9cf;
background: white;
color: #17243a;
}
button {
display: inline-block;
border: 0;
background: #2563eb;
color: white;
padding: 0.7rem 1rem;
cursor: pointer;
margin: 0.3rem 0.5rem 0.3rem 0;
}
button:disabled {
opacity: 0.55;
cursor: wait;
}
.secondary {
background: #e4eaf5;
color: #17243a;
}
.danger {
background: #b42332;
}
.narrow {
max-width: 480px;
margin: auto;
}
</style>
Say: "Replace the placeholder with account forms. Use the session API and rotated CSRF token; do not read the HttpOnly cookie from JavaScript."
Type: Follow the action above in modules/auth/ui/pages/Login.html; explain each new function or binding as you add it.
Viewers should see: The saved file matches this step. After all steps, run the chapter checks below and show the expected result.
Step 2: EDIT - replace the entire file - modules/projects/ui/pages/ProjectList.html
Replace the placeholder with the complete projects screen. Connect its form to the typed API helper; show loading, error and empty states. Keep all CSS inside style scoped.
Select all existing contents of this file and replace them with the complete code below.
<template>
<section>
<h1>Your projects</h1>
<p>Choose a project to manage its tasks.</p>
<p v-if="message" role="alert">
{{ message }} <a href="/login" data-teloce-link>Account</a>
</p>
<p v-if="loading" role="status">Loading projects…</p>
<form @submit.prevent="createProject" class="card">
<h2>Create a project</h2>
<label
>Name<input name="project-name" v-model="name" required maxlength="120"
/></label>
<label
>Description<textarea v-model="description" maxlength="1000"></textarea>
</label>
<button :disabled="busy">{{ busy ? "Saving…" : "Create project" }}</button>
</form>
<p v-if="!loading && projects.length === 0">
No projects yet. Create your first one above.
</p>
<div class="cards">
<a
v-for="project in projects"
:key="project.id"
:href="'/projects/' + project.id"
data-teloce-link
class="card"
>
<h2>{{ project.name }}</h2>
<p>{{ project.description }}</p>
</a>
</div>
</section>
</template>
<script lang="ts">
import { api } from "../../../../ui/api.ts";
import type { Project } from "../../../../ui/types.ts";
export default {
data() {
return {
projects: [],
name: "",
description: "",
message: "",
loading: true,
busy: false,
};
},
async mounted() {
try {
this.projects = await api<Project[]>("/api/projects/");
} catch (error) {
this.message = error.message;
} finally {
this.loading = false;
}
},
methods: {
async createProject() {
if (this.busy) return;
this.busy = true;
this.message = "";
try {
const project = await api<Project>("/api/projects/", "POST", {
name: this.name,
description: this.description,
});
this.projects = [project, ...this.projects];
this.name = "";
this.description = "";
} catch (error) {
this.message = error.message;
} finally {
this.busy = false;
}
},
},
};
</script>
<style scoped>
* {
box-sizing: border-box;
}
a {
color: #1d4ed8;
}
h1 {
font-size: clamp(1.8rem, 5vw, 3rem);
line-height: 1.15;
}
h2 {
font-size: 1.2rem;
}
p {
line-height: 1.6;
}
[role="alert"] {
color: #a21c2b;
padding: 0.8rem;
border-left: 4px solid;
}
:focus-visible {
outline: 3px solid #f59e0b;
outline-offset: 3px;
}
.card {
display: block;
background: white;
border: 1px solid #d8e0ed;
border-radius: 12px;
padding: 1.2rem;
margin: 1rem 0;
color: inherit;
text-decoration: none;
}
label {
display: grid;
gap: 0.4rem;
margin: 0.9rem 0;
font-weight: 600;
}
input,
select,
textarea,
button {
font: inherit;
border-radius: 7px;
padding: 0.7rem;
min-height: 44px;
}
input,
select,
textarea {
width: 100%;
border: 1px solid #abb9cf;
background: white;
color: #17243a;
}
button {
display: inline-block;
border: 0;
background: #2563eb;
color: white;
padding: 0.7rem 1rem;
cursor: pointer;
margin: 0.3rem 0.5rem 0.3rem 0;
}
button:disabled {
opacity: 0.55;
cursor: wait;
}
.secondary {
background: #e4eaf5;
color: #17243a;
}
.danger {
background: #b42332;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr));
gap: 1rem;
}
</style>
Say: "Replace the placeholder with the complete projects screen. Connect its form to the typed API helper; show loading, error and empty states. Keep all CSS inside style scoped."
Type: Follow the action above in modules/projects/ui/pages/ProjectList.html; explain each new function or binding as you add it.
Viewers should see: The saved file matches this step. After all steps, run the chapter checks below and show the expected result.
Run and check
python management.py runserver
# Open /login; register; create a project on /projects.
# Project details are completed in chapter 10.
Expected result
Register in /login, open /projects and create a project. Sign out and sign back in. The new project survives the restart.
Common errors
Always inspect response.ok. A resolved fetch promise can still represent a 400/403/500. Relative imports are resolved from each HTML component. Registration must refresh the cached CSRF token.
Short exercise
Show the validation message for an empty project name and make the same denied request directly to the API.
What to say and show
Say: "Replace placeholders with working account forms and a project list. The server remains responsible for persistence and authorization; the browser presents the result."
Show the expected result above, then point to the file responsible for it. Run the chapter check before the next lesson.
Save your checkpoint
git add .
git commit -m "Complete chapter 09: Login and project screens"