Guards

Guards demo built with Expressive MVC - the complete source below runs as an editable sandbox when JavaScript is enabled.

App.tsx

import './App.css';

import { State, Component } from '@expressive/mvc';
import { Link, Route, Router } from '@expressive/router';

const DOCS = ['charter', 'ledger'];

export default () => (
  <div className="container">
    <h1>Guards</h1>
    <p>
      A <code>redirect</code> function is an entry guard: returning a path
      redirects, returning nothing admits, and <code>null</code> cedes to the
      scope's <code>none</code> Route. Session and router are fields of the
      component declaring the routes, so the guard reads both directly.
    </p>
    <Guarded />
    <small>
      Signed out, both vault links land on sign-in. Signed in, known documents
      open and unknown ones get the vault's not-found page, not the app's. Each
      document is checked on entry. During the check the current screen
      holds; <code>fallback</code> shows only on a cold load.
    </small>
  </div>
);

class Session extends State {
  user: string | null = null;

  toggle() {
    this.user = this.user ? null : 'Ada';
  }
}

class Guarded extends Component {
  session = new Session();
  router = new Router();

  async vet() {
    if (!this.session.user) return '/login';

    await new Promise((resolve) => setTimeout(resolve, 600));

    return DOCS.includes(this.router.path.split('/').pop()!) ? '' : null;
  }

  render() {
    const { vet } = this;

    return (
      <Route as={Frame}>
        <Route as={Lobby} />
        <Route to="login" as={Login} />
        <Route to="vault">
          <Route to=":doc" as={Doc} redirect={vet} fallback={<p className="gate">checking…</p>} />
          <Route none as={VaultNotFound} />
        </Route>
        <Route none as={AppNotFound} />
      </Route>
    );
  }
}

const Frame = (props: { children?: Component.Node }) => (
  <>
    <nav className="nav">
      <Link to="/">Lobby</Link>
      <Link to="/vault/charter">Charter</Link>
      <Link to="/vault/secrets">Secrets</Link>
      <Link to="/missing">Outside vault</Link>
    </nav>
    <div className="view">{props.children}</div>
  </>
);

const Lobby = () => {
  const { user, toggle } = Session.get();

  return (
    <p className="gate">
      {user ? `Signed in as ${user}` : 'Signed out'} -{' '}
      <button onClick={toggle}>{user ? 'Sign out' : 'Sign in'}</button>
    </p>
  );
};

const Login = () => (
  <p className="gate">
    The guard sent you here. Sign in from the lobby, then try the vault again.
  </p>
);

const Doc = () => {
  const { match } = Route.get();

  return <p className="doc">Reading {match?.doc}</p>;
};

const VaultNotFound = () => <p className="gate">No such document in the vault.</p>;

const AppNotFound = () => <p className="gate">No application page matches this URL.</p>;

App.css

.nav {
  display: flex;
  gap: var(--s3);
  width: 100%;
  padding-bottom: var(--s3);
  border-bottom: 1px solid var(--accent-soft);
}

.nav a {
  cursor: pointer;
}

.view {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--s3);
  width: 100%;
  min-height: 4rem;
}

.gate,
.doc {
  margin: 0;
  padding: var(--s3) var(--s4);
  width: 100%;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--r);
}

.doc {
  color: var(--accent);
  border-color: var(--accent);
  background: var(--accent-soft);
}