Protected routes
Guards that send you to log in, and back to where you were going.
One beforeEach() guard reads each route's meta.
Without a session it sends you to the login page, with the address you
wanted in the query string, and the login page sends you back there.
The admin page also checks the role. The guard runs when the app opens
on a private address too, and on the back and forward buttons.
Preview
Code
Save it as an .html file and open it in a browser. It loads
Signy and Iris CSS from the CDN, so there is nothing to install or build.
Show the full page
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Protected routes - Signy example</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/iriscss@1/dist/iris.min.css">
<script src="https://cdn.jsdelivr.net/npm/signy@1/dist/signy.global.min.js"></script>
</head>
<body>
<div id="app"></div>
<script>
const {
Component, Signal, application, html, router, Link, Outlet, Show,
createContext, useQuery, useRouter,
} = Signy;
// The session lives outside the components, so the guards can read it too
const session = new Signal(null);
const SessionContext = createContext(session, "Session");
const USERS = {
ada: { name: "Ada", role: "admin" },
grace: { name: "Grace", role: "member" },
};
// A pretend auth server that takes half a second
const signIn = (username) =>
new Promise((resolve, reject) =>
setTimeout(() => (USERS[username] ? resolve(USERS[username]) : reject(new Error("Unknown user."))), 500),
);
class Home extends Component {
render() {
return html`
<h1>Welcome</h1>
<p>Anyone can read this page. <strong>Account</strong> needs a session, <strong>Admin</strong> needs the admin role.</p>
<p>Sign in as <code>ada</code> (admin) or <code>grace</code> (member).</p>
`;
}
}
class Login extends Component {
busy = new Signal(false);
error = new Signal("");
render() {
const query = useQuery(this);
const nav = useRouter(this);
const submit = async (event) => {
event.preventDefault();
this.busy.value = true;
this.error.value = "";
try {
session.value = await signIn(event.currentTarget.elements.username.value.trim().toLowerCase());
await nav.replace(query.peek().next || "/account");
} catch (error) {
this.error.value = error.message;
} finally {
this.busy.value = false;
}
};
return html`
<h1>Sign in</h1>
${Show({
when: () => query.value.next,
children: (next) => html`<p class="alert info" role="note">Sign in to open <code>${next}</code>.</p>`,
})}
<form onSubmit=${submit}>
<label for="username">User name</label>
<input id="username" name="username" autocomplete="username" aria-describedby="login-error" />
<small id="login-error">${() => this.error.value}</small>
<button class="primary" aria-busy=${() => String(this.busy.value)} disabled=${() => this.busy.value}>Sign in</button>
</form>
`;
}
}
class Account extends Component {
render() {
const user = SessionContext.inject(this);
return html`
<h1>Your account</h1>
<p>Signed in as ${() => user.value?.name}, with the ${() => user.value?.role} role.</p>
`;
}
}
class Admin extends Component {
render() {
return html`
<h1>Admin</h1>
<p class="alert success" role="status">Only admins see this page.</p>
`;
}
}
class Forbidden extends Component {
render() {
return html`
<h1>Not allowed</h1>
<p>Your role cannot open the admin page.</p>
`;
}
}
class Layout extends Component {
setup() {
SessionContext.provide(this, session);
}
render() {
const nav = useRouter(this);
const signOut = () => {
session.value = null;
nav.push("/");
};
return html`
<header class="navbar">
<nav class="container" aria-label="Main">
<ul>
<li><${Link} to="/"><strong>Members club</strong></${Link}></li>
</ul>
<ul>
<li><${Link} to="/account">Account</${Link}></li>
<li><${Link} to="/admin">Admin</${Link}></li>
<li>
${Show({
when: () => session.value,
fallback: html`<${Link} to="/login">Sign in</${Link}>`,
children: (user) => html`
<button class="small outlined" onClick=${signOut}>Sign out ${user.name}</button>
`,
})}
</li>
</ul>
</nav>
</header>
<main class="container">
<${Outlet} />
</main>
`;
}
}
const appRouter = router({
mode: "hash",
routes: [
{ path: "/", component: Home },
{ path: "/login", component: Login },
{ path: "/account", component: Account, meta: { private: true } },
{ path: "/admin", component: Admin, meta: { private: true, role: "admin" } },
{ path: "/forbidden", component: Forbidden },
{ path: "*", component: Home },
],
});
// One guard for every route. It also runs when the app opens on a private
// address and on the back and forward buttons. Return nothing to go on, or
// the address to go to instead.
appRouter.beforeEach(({ to }) => {
if (!to.meta.private) return;
const user = session.peek();
if (!user) return { path: "/login", query: { next: to.path } };
if (to.meta.role && user.role !== to.meta.role) return "/forbidden";
});
application(Layout).use(appRouter).mount("#app");
</script>
</body>
</html>
What it uses
- Guards with
beforeEach()that return a redirect - Route
metafor the rules - useQuery() for the return address
- Context for the session