Skip to main content
This is the longest part of the series, because authentication, cloud storage and the admin panel arrive together. Most of that surface is already built. Google sign-in, the approval workflow, a user admin panel with analytics and error logs, and a storage interface are scaffolded or provided, so the prompt below is mostly asking for your domain model on top of them. Sort your credentials out first. Nothing here runs without them.

What you’ll have

  • Google sign-in, with new accounts held for approval
  • A dashboard listing your boards, and multiple boards per user
  • Roles doing real work: editors and admins can change things, everyone else reads
  • The admin panel at /admin/ for approving and promoting users
  • PDF attachments on cards, stored in Google Cloud Storage
  • A board exported to PDF on demand

Which parts of Feather this uses

Authentication

Google OAuth, the approval workflow, @auth_required and @login_only.

Admin panel

Scaffolded into your app, not hidden in the framework.

File storage

get_storage() covers local files and GCS behind one interface.

PDF generation

WeasyPrint turns HTML and CSS into the exported document.

Step 1: Credentials

No Google Cloud project? Skip both rows. Scaffold with feather new kanban --sign-in email and people sign in with a link sent to their inbox: in development the link is written to the log, and on Appentic the email is sent for you. Set STORAGE_BACKEND_FALLBACK = "local" in config.py and attachments go to disk while you build. On Appentic, provisioning file storage sets S3_BUCKET, and the same get_storage() code writes to the bucket. The prompts below work unchanged: say “email sign-in” where they say Google.
  1. Open APIs and Credentials.
  2. Create an OAuth 2.0 Client ID of type Web application.
  3. Add http://localhost:5173/auth/google/callback as an authorized redirect URI.
  4. Copy the client ID and secret.
  1. Create a bucket in Cloud Storage.
  2. Create a service account in IAM.
  3. Grant it the Storage Object Admin role.
  4. Create a JSON key and copy the entire contents.

Step 2: Scaffold

This part needs a different app type, so start fresh.
Four answers matter:
The first migration is already generated and applied. If Postgres was not running while the app was created, run feather db migrate -m "Initial migration" and feather db upgrade first.
.env
GCS_CREDENTIALS_JSON has to be the whole JSON key on one line. Copy it out of the downloaded file and strip the line breaks, or uploads fail against the bucket with an error that doesn’t obviously point back here.

Step 3: Run the first prompt

Two prompts, because asking for auth, storage and export in one go is asking for too much. The first gets you a working app behind sign-in.
Prompt 1 of 2: auth and boards

Step 4: Run the second prompt

Once sign-in works and boards render, add the files and the export.
Prompt 2 of 2: attachments and export

Step 5: Test it

  1. Sign in with Google. A brand new account should land on the pending page.
  2. Approve yourself from /admin/users as the admin created by seeds.py.
  3. Create a board from the dashboard, then open it and add columns and cards.
  4. Go back and create a second board. Both should be listed.
  5. Attach a PDF to a card, then open it in the viewer.
  6. Click Export PDF and check the download.
  7. Open /admin/ and confirm the user list and analytics render.
Then check the roles behave. Set a second account to the plain user role from /admin/users and confirm it sees the boards with no create, edit or delete controls.

What the framework did

You wrote no sign-in code. Google OAuth, the session, the callback and the user record are all Feather’s. What the prompt asked for was the domain model on top. See Authentication. The approval gate is two fields. A user is pending when active is false and approved_at is null, and @auth_required raises AccountPendingError for them, which redirects to the pending page instead of returning a bare 403. That’s why the pending page itself uses @login_only. See Exceptions. The admin panel is your code. It was scaffolded into routes/pages/admin.py and templates/pages/admin/, so you can change any of it. See Admin panel. Uploads went through a storage interface. get_storage() returns the configured backend, which is why the same code works against the local filesystem. Script-capable extensions are refused by default, which is a real protection given files are served from your own origin. See File storage. Authorization ran twice, on purpose. The service refuses the action and the template hides the button. Either alone is a bug: service-only shows controls that fail, template-only is trivially bypassed.

Next: turn it into a SaaS

Tenant isolation, platform admin, and queries that can’t leak across companies.