diff --git a/app.py b/app.py index fa0d025..dd43b39 100644 --- a/app.py +++ b/app.py @@ -1,12 +1,14 @@ from __future__ import annotations import path_setup # noqa: F401 # adds shared/ to sys.path +from pathlib import Path from quart import g +from jinja2 import FileSystemLoader, ChoiceLoader from shared.infrastructure.factory import create_base_app from shared.services.registry import services -from bp import register_account_bp +from bp import register_account_bp, register_auth_bp async def account_context() -> dict: @@ -39,7 +41,15 @@ def create_app() -> "Quart": domain_services_fn=register_domain_services, ) + # App-specific templates override shared templates + app_templates = str(Path(__file__).resolve().parent / "templates") + app.jinja_loader = ChoiceLoader([ + FileSystemLoader(app_templates), + app.jinja_loader, + ]) + # --- blueprints --- + app.register_blueprint(register_auth_bp()) app.register_blueprint(register_account_bp()) return app diff --git a/blog/__init__.py b/blog/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/blog/models/__init__.py b/blog/models/__init__.py new file mode 100644 index 0000000..e434f4a --- /dev/null +++ b/blog/models/__init__.py @@ -0,0 +1,14 @@ +from .ghost_content import Post, Author, Tag, PostAuthor, PostTag, PostLike +from .snippet import Snippet +from .tag_group import TagGroup, TagGroupTag + +# Shared models — canonical definitions live in shared/models/ +from shared.models.ghost_membership_entities import ( + GhostLabel, UserLabel, + GhostNewsletter, UserNewsletter, + GhostTier, GhostSubscription, +) +from shared.models.menu_item import MenuItem +from shared.models.kv import KV +from shared.models.magic_link import MagicLink +from shared.models.user import User diff --git a/blog/models/ghost_content.py b/blog/models/ghost_content.py new file mode 100644 index 0000000..cd18161 --- /dev/null +++ b/blog/models/ghost_content.py @@ -0,0 +1,3 @@ +from shared.models.ghost_content import ( # noqa: F401 + Tag, Post, Author, PostAuthor, PostTag, PostLike, +) diff --git a/blog/models/ghost_membership_entities.py b/blog/models/ghost_membership_entities.py new file mode 100644 index 0000000..d07520f --- /dev/null +++ b/blog/models/ghost_membership_entities.py @@ -0,0 +1,12 @@ +# Re-export from canonical shared location +from shared.models.ghost_membership_entities import ( + GhostLabel, UserLabel, + GhostNewsletter, UserNewsletter, + GhostTier, GhostSubscription, +) + +__all__ = [ + "GhostLabel", "UserLabel", + "GhostNewsletter", "UserNewsletter", + "GhostTier", "GhostSubscription", +] diff --git a/blog/models/kv.py b/blog/models/kv.py new file mode 100644 index 0000000..d54f0a3 --- /dev/null +++ b/blog/models/kv.py @@ -0,0 +1,4 @@ +# Re-export from canonical shared location +from shared.models.kv import KV + +__all__ = ["KV"] diff --git a/blog/models/magic_link.py b/blog/models/magic_link.py new file mode 100644 index 0000000..9031ca4 --- /dev/null +++ b/blog/models/magic_link.py @@ -0,0 +1,4 @@ +# Re-export from canonical shared location +from shared.models.magic_link import MagicLink + +__all__ = ["MagicLink"] diff --git a/blog/models/menu_item.py b/blog/models/menu_item.py new file mode 100644 index 0000000..f36a146 --- /dev/null +++ b/blog/models/menu_item.py @@ -0,0 +1,4 @@ +# Re-export from canonical shared location +from shared.models.menu_item import MenuItem + +__all__ = ["MenuItem"] diff --git a/blog/models/snippet.py b/blog/models/snippet.py new file mode 100644 index 0000000..47cad35 --- /dev/null +++ b/blog/models/snippet.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import Integer, String, Text, DateTime, ForeignKey, UniqueConstraint, Index, func +from sqlalchemy.orm import Mapped, mapped_column + +from shared.db.base import Base + + +class Snippet(Base): + __tablename__ = "snippets" + __table_args__ = ( + UniqueConstraint("user_id", "name", name="uq_snippets_user_name"), + Index("ix_snippets_visibility", "visibility"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, + ) + name: Mapped[str] = mapped_column(String(255), nullable=False) + value: Mapped[str] = mapped_column(Text, nullable=False) + visibility: Mapped[str] = mapped_column( + String(20), nullable=False, default="private", server_default="private", + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(), + ) diff --git a/blog/models/tag_group.py b/blog/models/tag_group.py new file mode 100644 index 0000000..77ddc41 --- /dev/null +++ b/blog/models/tag_group.py @@ -0,0 +1,52 @@ +from datetime import datetime +from typing import List, Optional +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy import ( + Integer, + String, + Text, + DateTime, + ForeignKey, + UniqueConstraint, + func, +) +from shared.db.base import Base + + +class TagGroup(Base): + __tablename__ = "tag_groups" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + slug: Mapped[str] = mapped_column(String(191), unique=True, nullable=False) + feature_image: Mapped[Optional[str]] = mapped_column(Text()) + colour: Mapped[Optional[str]] = mapped_column(String(32)) + sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() + ) + + tag_links: Mapped[List["TagGroupTag"]] = relationship( + "TagGroupTag", back_populates="group", cascade="all, delete-orphan", passive_deletes=True + ) + + +class TagGroupTag(Base): + __tablename__ = "tag_group_tags" + __table_args__ = ( + UniqueConstraint("tag_group_id", "tag_id", name="uq_tag_group_tag"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + tag_group_id: Mapped[int] = mapped_column( + ForeignKey("tag_groups.id", ondelete="CASCADE"), nullable=False + ) + tag_id: Mapped[int] = mapped_column( + ForeignKey("tags.id", ondelete="CASCADE"), nullable=False + ) + + group: Mapped["TagGroup"] = relationship("TagGroup", back_populates="tag_links") diff --git a/blog/models/user.py b/blog/models/user.py new file mode 100644 index 0000000..3feae81 --- /dev/null +++ b/blog/models/user.py @@ -0,0 +1,4 @@ +# Re-export from canonical shared location +from shared.models.user import User + +__all__ = ["User"] diff --git a/bp/__init__.py b/bp/__init__.py index 9fb7501..2113b69 100644 --- a/bp/__init__.py +++ b/bp/__init__.py @@ -1 +1,2 @@ from .account.routes import register as register_account_bp +from .auth.routes import register as register_auth_bp diff --git a/bp/auth/__init__.py b/bp/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bp/auth/routes.py b/bp/auth/routes.py new file mode 100644 index 0000000..6ec78c8 --- /dev/null +++ b/bp/auth/routes.py @@ -0,0 +1,232 @@ +"""Authentication routes for the account app. + +Account is the OAuth authorization server. Owns magic link login/logout, +OAuth2 authorize endpoint, and SSO logout. +""" +from __future__ import annotations + +import secrets +from datetime import datetime, timezone, timedelta + +from quart import ( + Blueprint, + request, + render_template, + redirect, + url_for, + session as qsession, + g, + current_app, +) +from sqlalchemy import select +from sqlalchemy.exc import SQLAlchemyError + +from shared.db.session import get_session +from shared.models import User +from shared.models.oauth_code import OAuthCode +from shared.infrastructure.urls import account_url, app_url +from shared.infrastructure.cart_identity import current_cart_identity +from shared.events import emit_activity + +from .services import ( + pop_login_redirect_target, + store_login_redirect_target, + send_magic_email, + find_or_create_user, + create_magic_link, + validate_magic_link, + validate_email, +) + +SESSION_USER_KEY = "uid" + +ALLOWED_CLIENTS = {"blog", "market", "cart", "events", "federation"} + + +def register(url_prefix="/auth"): + auth_bp = Blueprint("auth", __name__, url_prefix=url_prefix) + + # --- OAuth2 authorize endpoint ------------------------------------------- + + @auth_bp.get("/oauth/authorize") + @auth_bp.get("/oauth/authorize/") + async def oauth_authorize(): + client_id = request.args.get("client_id", "") + redirect_uri = request.args.get("redirect_uri", "") + state = request.args.get("state", "") + + if client_id not in ALLOWED_CLIENTS: + return "Invalid client_id", 400 + + expected_redirect = app_url(client_id, "/auth/callback") + if redirect_uri != expected_redirect: + return "Invalid redirect_uri", 400 + + # Not logged in — bounce to magic link login, then back here + if not g.get("user"): + # Preserve the full authorize URL so we return here after login + authorize_path = request.full_path # includes query string + store_login_redirect_target() + return redirect(url_for("auth.login_form", next=authorize_path)) + + # Logged in — issue authorization code + code = secrets.token_urlsafe(48) + now = datetime.now(timezone.utc) + expires = now + timedelta(minutes=5) + + async with get_session() as s: + async with s.begin(): + oauth_code = OAuthCode( + code=code, + user_id=g.user.id, + client_id=client_id, + redirect_uri=redirect_uri, + expires_at=expires, + ) + s.add(oauth_code) + + sep = "&" if "?" in redirect_uri else "?" + return redirect(f"{redirect_uri}{sep}code={code}&state={state}") + + # --- Magic link login flow ----------------------------------------------- + + @auth_bp.get("/login/") + async def login_form(): + store_login_redirect_target() + cross_cart_sid = request.args.get("cart_sid") + if cross_cart_sid: + qsession["cart_sid"] = cross_cart_sid + if g.get("user"): + # If there's a pending redirect (e.g. OAuth authorize), follow it + redirect_url = pop_login_redirect_target() + return redirect(redirect_url) + return await render_template("auth/login.html") + + @auth_bp.post("/start/") + async def start_login(): + form = await request.form + email_input = form.get("email") or "" + + is_valid, email = validate_email(email_input) + if not is_valid: + return ( + await render_template( + "auth/login.html", + error="Please enter a valid email address.", + email=email_input, + ), + 400, + ) + + user = await find_or_create_user(g.s, email) + token, expires = await create_magic_link(g.s, user.id) + + from shared.utils import host_url + magic_url = host_url(url_for("auth.magic", token=token)) + + email_error = None + try: + await send_magic_email(email, magic_url) + except Exception as e: + current_app.logger.error("EMAIL SEND FAILED: %r", e) + email_error = ( + "We couldn't send the email automatically. " + "Please try again in a moment." + ) + + return await render_template( + "auth/check_email.html", + email=email, + email_error=email_error, + ) + + @auth_bp.get("/magic//") + async def magic(token: str): + now = datetime.now(timezone.utc) + user_id: int | None = None + + try: + async with get_session() as s: + async with s.begin(): + user, error = await validate_magic_link(s, token) + + if error: + return ( + await render_template("auth/login.html", error=error), + 400, + ) + user_id = user.id + + except Exception: + return ( + await render_template( + "auth/login.html", + error="Could not sign you in right now. Please try again.", + ), + 502, + ) + + assert user_id is not None + + ident = current_cart_identity() + anon_session_id = ident.get("session_id") + + try: + async with get_session() as s: + async with s.begin(): + u2 = await s.get(User, user_id) + if u2: + u2.last_login_at = now + if anon_session_id: + await emit_activity( + s, + activity_type="rose:Login", + actor_uri="internal:system", + object_type="Person", + object_data={ + "user_id": user_id, + "session_id": anon_session_id, + }, + ) + except SQLAlchemyError: + current_app.logger.exception( + "[auth] non-fatal DB update for user_id=%s", user_id + ) + + qsession[SESSION_USER_KEY] = user_id + + redirect_url = pop_login_redirect_target() + resp = redirect(redirect_url, 303) + resp.set_cookie( + "sso_hint", "1", + domain=".rose-ash.com", max_age=30 * 24 * 3600, + secure=True, samesite="Lax", httponly=True, + ) + return resp + + @auth_bp.post("/logout/") + async def logout(): + qsession.pop(SESSION_USER_KEY, None) + resp = redirect(account_url("/")) + resp.delete_cookie("sso_hint", domain=".rose-ash.com", path="/") + return resp + + @auth_bp.get("/clear/") + async def clear(): + """One-time migration helper: clear all session cookies.""" + qsession.clear() + resp = redirect(account_url("/")) + resp.delete_cookie("blog_session", domain=".rose-ash.com", path="/") + resp.delete_cookie("sso_hint", domain=".rose-ash.com", path="/") + return resp + + @auth_bp.get("/sso-logout/") + async def sso_logout(): + """SSO logout: clear account session + sso_hint, redirect to blog.""" + qsession.pop(SESSION_USER_KEY, None) + from shared.infrastructure.urls import blog_url + resp = redirect(blog_url("/")) + resp.delete_cookie("sso_hint", domain=".rose-ash.com", path="/") + return resp + + return auth_bp diff --git a/bp/auth/services/__init__.py b/bp/auth/services/__init__.py new file mode 100644 index 0000000..648f87d --- /dev/null +++ b/bp/auth/services/__init__.py @@ -0,0 +1,24 @@ +from .login_redirect import pop_login_redirect_target, store_login_redirect_target +from .auth_operations import ( + get_app_host, + get_app_root, + send_magic_email, + load_user_by_id, + find_or_create_user, + create_magic_link, + validate_magic_link, + validate_email, +) + +__all__ = [ + "pop_login_redirect_target", + "store_login_redirect_target", + "get_app_host", + "get_app_root", + "send_magic_email", + "load_user_by_id", + "find_or_create_user", + "create_magic_link", + "validate_magic_link", + "validate_email", +] diff --git a/bp/auth/services/auth_operations.py b/bp/auth/services/auth_operations.py new file mode 100644 index 0000000..f727c0d --- /dev/null +++ b/bp/auth/services/auth_operations.py @@ -0,0 +1,156 @@ +"""Auth operations for the account app. + +Owns magic-link login. Shared models, shared config. +""" +from __future__ import annotations + +import os +import secrets +from datetime import datetime, timedelta, timezone +from typing import Optional, Tuple + +from quart import current_app, render_template, request, g +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from shared.models import User, MagicLink +from shared.config import config + + +def get_app_host() -> str: + host = ( + config().get("host") or os.getenv("APP_HOST") or "http://localhost:8000" + ).rstrip("/") + return host + + +def get_app_root() -> str: + root = (g.root).rstrip("/") + return root + + +async def send_magic_email(to_email: str, link_url: str) -> None: + host = os.getenv("SMTP_HOST") + port = int(os.getenv("SMTP_PORT") or "587") + username = os.getenv("SMTP_USER") + password = os.getenv("SMTP_PASS") + mail_from = os.getenv("MAIL_FROM") or "no-reply@example.com" + + site_name = config().get("title", "Rose Ash") + subject = f"Your sign-in link \u2014 {site_name}" + + tpl_vars = dict(site_name=site_name, link_url=link_url) + text_body = await render_template("_email/magic_link.txt", **tpl_vars) + html_body = await render_template("_email/magic_link.html", **tpl_vars) + + if not host or not username or not password: + current_app.logger.warning( + "SMTP not configured. Printing magic link to console for %s: %s", + to_email, + link_url, + ) + print(f"[DEV] Magic link for {to_email}: {link_url}") + return + + import aiosmtplib + from email.message import EmailMessage + + msg = EmailMessage() + msg["From"] = mail_from + msg["To"] = to_email + msg["Subject"] = subject + msg.set_content(text_body) + msg.add_alternative(html_body, subtype="html") + + is_secure = port == 465 + if is_secure: + smtp = aiosmtplib.SMTP( + hostname=host, port=port, use_tls=True, + username=username, password=password, + ) + else: + smtp = aiosmtplib.SMTP( + hostname=host, port=port, start_tls=True, + username=username, password=password, + ) + + async with smtp: + await smtp.send_message(msg) + + +async def load_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]: + stmt = ( + select(User) + .options(selectinload(User.labels)) + .where(User.id == user_id) + ) + result = await session.execute(stmt) + return result.scalar_one_or_none() + + +async def find_or_create_user(session: AsyncSession, email: str) -> User: + result = await session.execute(select(User).where(User.email == email)) + user = result.scalar_one_or_none() + + if user is None: + user = User(email=email) + session.add(user) + await session.flush() + + return user + + +async def create_magic_link( + session: AsyncSession, + user_id: int, + purpose: str = "signin", + expires_minutes: int = 15, +) -> Tuple[str, datetime]: + token = secrets.token_urlsafe(32) + expires = datetime.now(timezone.utc) + timedelta(minutes=expires_minutes) + + ml = MagicLink( + token=token, + user_id=user_id, + purpose=purpose, + expires_at=expires, + ip=request.headers.get("x-forwarded-for", request.remote_addr), + user_agent=request.headers.get("user-agent"), + ) + session.add(ml) + + return token, expires + + +async def validate_magic_link( + session: AsyncSession, + token: str, +) -> Tuple[Optional[User], Optional[str]]: + now = datetime.now(timezone.utc) + + ml = await session.scalar( + select(MagicLink) + .where(MagicLink.token == token) + .with_for_update() + ) + + if not ml or ml.purpose != "signin": + return None, "Invalid or expired link." + + if ml.used_at or ml.expires_at < now: + return None, "This link has expired. Please request a new one." + + user = await session.get(User, ml.user_id) + if not user: + return None, "User not found." + + ml.used_at = now + return user, None + + +def validate_email(email: str) -> Tuple[bool, str]: + email = email.strip().lower() + if not email or "@" not in email: + return False, email + return True, email diff --git a/bp/auth/services/login_redirect.py b/bp/auth/services/login_redirect.py new file mode 100644 index 0000000..8382516 --- /dev/null +++ b/bp/auth/services/login_redirect.py @@ -0,0 +1,45 @@ +from urllib.parse import urlparse +from quart import session + +from shared.infrastructure.urls import account_url + + +LOGIN_REDIRECT_SESSION_KEY = "login_redirect_to" + + +def store_login_redirect_target() -> None: + from quart import request + + target = request.args.get("next") + if not target: + ref = request.referrer or "" + try: + parsed = urlparse(ref) + target = parsed.path or "" + except Exception: + target = "" + + if not target: + return + + # Accept both relative paths and absolute URLs (cross-app redirects) + if target.startswith("http://") or target.startswith("https://"): + session[LOGIN_REDIRECT_SESSION_KEY] = target + elif target.startswith("/") and not target.startswith("//"): + session[LOGIN_REDIRECT_SESSION_KEY] = target + + +def pop_login_redirect_target() -> str: + path = session.pop(LOGIN_REDIRECT_SESSION_KEY, None) + if not path or not isinstance(path, str): + return account_url("/") + + # Absolute URL: return as-is (cross-app redirect) + if path.startswith("http://") or path.startswith("https://"): + return path + + # Relative path: must start with / and not // + if path.startswith("/") and not path.startswith("//"): + return account_url(path) + + return account_url("/") diff --git a/cart/__init__.py b/cart/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cart/models/__init__.py b/cart/models/__init__.py new file mode 100644 index 0000000..508c4b0 --- /dev/null +++ b/cart/models/__init__.py @@ -0,0 +1,2 @@ +from .order import Order, OrderItem +from .page_config import PageConfig diff --git a/cart/models/order.py b/cart/models/order.py new file mode 100644 index 0000000..93953fe --- /dev/null +++ b/cart/models/order.py @@ -0,0 +1 @@ +from shared.models.order import Order, OrderItem # noqa: F401 diff --git a/cart/models/page_config.py b/cart/models/page_config.py new file mode 100644 index 0000000..ec23c6d --- /dev/null +++ b/cart/models/page_config.py @@ -0,0 +1 @@ +from shared.models.page_config import PageConfig # noqa: F401 diff --git a/events/__init__.py b/events/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/events/models/__init__.py b/events/models/__init__.py new file mode 100644 index 0000000..4006b10 --- /dev/null +++ b/events/models/__init__.py @@ -0,0 +1,4 @@ +from .calendars import ( + Calendar, CalendarEntry, CalendarSlot, + TicketType, Ticket, CalendarEntryPost, +) diff --git a/events/models/calendars.py b/events/models/calendars.py new file mode 100644 index 0000000..02025ff --- /dev/null +++ b/events/models/calendars.py @@ -0,0 +1,4 @@ +from shared.models.calendars import ( # noqa: F401 + Calendar, CalendarEntry, CalendarSlot, + TicketType, Ticket, CalendarEntryPost, +) diff --git a/market/__init__.py b/market/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/market/models/__init__.py b/market/models/__init__.py new file mode 100644 index 0000000..9ca9e79 --- /dev/null +++ b/market/models/__init__.py @@ -0,0 +1,8 @@ +from .market import ( + Product, ProductLike, ProductImage, ProductSection, + NavTop, NavSub, Listing, ListingItem, + LinkError, LinkExternal, SubcategoryRedirect, ProductLog, + ProductLabel, ProductSticker, ProductAttribute, ProductNutrition, ProductAllergen, + CartItem, +) +from .market_place import MarketPlace diff --git a/market/models/market.py b/market/models/market.py new file mode 100644 index 0000000..65511e1 --- /dev/null +++ b/market/models/market.py @@ -0,0 +1,7 @@ +from shared.models.market import ( # noqa: F401 + Product, ProductLike, ProductImage, ProductSection, + NavTop, NavSub, Listing, ListingItem, + LinkError, LinkExternal, SubcategoryRedirect, ProductLog, + ProductLabel, ProductSticker, ProductAttribute, ProductNutrition, ProductAllergen, + CartItem, +) diff --git a/market/models/market_place.py b/market/models/market_place.py new file mode 100644 index 0000000..ca65447 --- /dev/null +++ b/market/models/market_place.py @@ -0,0 +1 @@ +from shared.models.market_place import MarketPlace # noqa: F401 diff --git a/shared b/shared index 60cd08a..dfc41ad 160000 --- a/shared +++ b/shared @@ -1 +1 @@ -Subproject commit 60cd08adc917912a0c4da848a7ee9d38e9276115 +Subproject commit dfc41ada7db2d7458bc2641a80dd16ab96b123e7 diff --git a/templates/_email/magic_link.html b/templates/_email/magic_link.html new file mode 100644 index 0000000..3c1eac6 --- /dev/null +++ b/templates/_email/magic_link.html @@ -0,0 +1,33 @@ + + + + + + +
+ + +
+

{{ site_name }}

+

Sign in to your account

+

+ Click the button below to sign in. This link will expire in 15 minutes. +

+
+ + Sign in + +
+

Or copy and paste this link into your browser:

+

+ {{ link_url }} +

+
+

+ If you did not request this email, you can safely ignore it. +

+
+
+ + diff --git a/templates/_email/magic_link.txt b/templates/_email/magic_link.txt new file mode 100644 index 0000000..28a2efb --- /dev/null +++ b/templates/_email/magic_link.txt @@ -0,0 +1,8 @@ +Hello, + +Click this link to sign in: +{{ link_url }} + +This link will expire in 15 minutes. + +If you did not request this, you can ignore this email. diff --git a/templates/auth/check_email.html b/templates/auth/check_email.html new file mode 100644 index 0000000..5eb1b61 --- /dev/null +++ b/templates/auth/check_email.html @@ -0,0 +1,19 @@ +{% extends "_types/root/_index.html" %} +{% block meta %}{% endblock %} +{% block title %}Check your email — Rose Ash{% endblock %} +{% block content %} +
+

Check your email

+

+ We sent a sign-in link to {{ email }}. +

+

+ Click the link in the email to sign in. The link expires in 15 minutes. +

+ {% if email_error %} +
+ {{ email_error }} +
+ {% endif %} +
+{% endblock %} diff --git a/templates/auth/login.html b/templates/auth/login.html new file mode 100644 index 0000000..79031e5 --- /dev/null +++ b/templates/auth/login.html @@ -0,0 +1,36 @@ +{% extends "_types/root/_index.html" %} +{% block meta %}{% endblock %} +{% block title %}Login — Rose Ash{% endblock %} +{% block content %} +
+

Sign in

+ + {% if error %} +
+ {{ error }} +
+ {% endif %} + +
+ +
+ + +
+ +
+
+{% endblock %}