Your HR platform, on infrastructure you own.
A complete, self-hosted, open-source HR and workforce platform. Attendance, leave, timesheets, expenses, visitors, wellness, IT support, kiosk mode, single sign-on, immutable audit log, GDPR data export, monthly leave accruals, an optional AI assistant. One organisation per deployment, your Supabase project, your data.
Why this exists
Commercial HR software is priced per user per month and most small-to-medium organisations end up paying a few thousand pounds a year for capabilities they could self-host. The problem is not the capability list. The problem is the recurring cost on data they should own anyway and a vendor whose roadmap is not theirs.
StaffPortal exists because attendance, leave, expenses, and visitor management are not differentiating SaaS problems. They are well-understood patterns sitting on a Postgres database with a Next.js front end. Implemented carefully, with Row Level Security throughout, an immutable audit log, and a GDPR portability endpoint, the result is indistinguishable in day-to-day use from the commercial offerings and substantially better in the bits that matter for trust.
The deployment story is deliberately small. One Supabase project. One Vercel deployment. Numbered SQL migrations applied in order. Eight cron endpoints called by Vercel Cron or GitHub Actions with a bearer token. No separate backend service, no broker, no message bus. If you can run a Supabase project and a Vercel deployment, you can run this.
Built for one organisation per deployment. Not multi-tenant, not a SaaS. Fork it, brand it, run it, change anything. The repo is the contract; the wiki is the manual; pull requests are welcome.
HR data should live where the rest of your business does.
Commercial HR products charge per seat for capabilities that are well-understood patterns over a Postgres table. StaffPortal puts those patterns on infrastructure you already operate, with Row Level Security on every table, an append-only audit log of every privileged action and a one-click GDPR portability export. The bits that buy trust (isolation, auditability, data portability) are first-class, not paid add-ons. v2 widens that surface again, with a live time-in-office counter, a reception status board, multi-step approvals, a PIN-based kiosk, QR-coded visitor management, PDF leave certificates, year-end carry-forward, birthday and missing-attendance crons, and Excel timesheets with bank reconciliation, fifteen capabilities added in one release.
What v2 added
v2 turns StaffPortal from an attendance and leave system into a live workforce platform. A reception status board, a roll-call view, a PIN kiosk, QR-coded visitor management, four new cron jobs, multi-step leave approvals, PDF leave certificates and Excel timesheets with bank reconciliation, all ship on the same Supabase project.
Live time-in-office counter
A second-by-second running clock that deducts logged breaks. Visible to the employee on their dashboard and to admins on the reception board. Hard to game, easy to read.
Running late pre-logs
Staff pre-log a running-late entry for today or tomorrow. The dashboard shows an amber banner to the team, managers see the ETA before the train arrives.
WFH toggle with fan-out
One toggle marks the day as working-from-home and fans out a notification email to the configured recipients. Reflected on the reception board instantly.
Multi-step leave approval
Leave requests can require up to three approvers in priority order. Each approver gets emailed in turn, the chain advances as decisions land, the audit log records every step.
Year-end leave carry-forward
A cron job at year end carries forward each employee’s unused entitlement up to the configured cap. Idempotent, re-runnable, audited.
Birthday reminders
A cron emails colleagues two days before a birthday and the birthday person on the day. One config flag turns the whole thing off.
Forgotten clock-out at 19:00
A nightly cron at 19:00 finds anyone with an open clock-in and no clock-out, emails them, and surfaces the row to the admin dashboard for review.
Missing attendance report
A second 19:00 cron compiles a report of employees with no attendance for the day and emails the admin team. Sick, off, or forgotten, the row is in one place.
PIN-based kiosk at /kiosk
No login required. Staff clock in or out by PIN, visitors check in. The single route that bypasses Supabase auth and uses scoped server actions instead.
Visitor management with QR
Pre-registered visitors get a QR-coded reference. On arrival they scan in at the kiosk, the host is emailed, a printable PDF pass is generated, every step is audited.
Reception live status board
A live board for reception showing every employee bucketed by state: In office, WFH, On leave, Running late, Clocked out. Refreshes as states change.
Roll-call view
A who-is-in-the-building snapshot for fire drills and head-counts. Pulled live from clock events and WFH flags, not a stale snapshot.
PDF leave certificate
On leave approval, PDFKit renders a certificate and emails it to the employee. The same template handles annual, sick, maternity and paternity.
Diary notes with reminders
Personal diary notes inside the portal. Each note can carry a reminder timestamp, an email goes out at the right moment.
Excel timesheets + bank reconciliation
Monthly Excel timesheet export via ExcelJS. Bank statement reconciliation matches deposits against approved expenses for the accounts team.
v2 cron sweep + reception live board
Twelve crons keep the live state honest. Clock events, WFH flags, running-late entries and leave approvals all feed the reception board and roll-call view. Forgotten clock-outs, missing attendance, birthdays, and year-end carry-forward all fire on schedule.
Built-in features
Everything below ships in the repository. Free Supabase tier and free Vercel tier are enough to start.
Attendance and timesheets
Clock in, clock out, work-from-home toggle, late-arrival detection. Per-employee contracted hours with weekly summaries against actual hours. Correction requests with approval flow. Excel export via ExcelJS.
Leave management
Annual, sick, maternity / paternity, unpaid. Multi-step approvals with PDF certificates via PDFKit. Approved leave can be withdrawn with automatic balance reversal and a notification email. Team calendar with conflict detection.
Monthly leave accruals
Each balance carries an accrual_rate. A cron job tops every accruing balance up by elapsed months times its rate, capped at the configured entitlement. Idempotent: re-running the same month grants nothing further. Admins can preview and run on demand.
Single sign-on
Map an email domain to a provider under Admin then Single Sign-On. Microsoft Entra ID, Google Workspace, GitHub, GitLab via Supabase signInWithOAuth, SAML 2.0 via signInWithSSO. First-time SSO bootstraps a profile and leave balances. Every login is audited.
Expenses with receipt upload
Expense claims with category, merchant, amount, receipt upload to Supabase Storage. PDF claim forms generated on demand. Approval workflow with policy checks. Reconciliation export for the accounts team.
Purchase requests
Staff submit purchase requests for admin approval. Routed through the same approval engine as expenses and leave. Audit trail preserved end to end.
Visitor management
Pre-register visitors with QR code references. On arrival the host receives an email notification, a printable PDF pass is generated, and the audit log records the visit. Reception desk view for fast same-day check-in.
Public kiosk mode
Mount a tablet at the entrance at /kiosk. PIN authentication, touch-first UI. Staff clock in / out without logging into the web app; visitors check themselves in. The one route in the entire app that bypasses login.
Announcements and polls
Company-wide announcements with email broadcast via Resend. Acknowledgement-required posts for policy updates. Polls with real-time vote counts.
Analytics with CSV export
Late arrivals, WFH trends, office presence, contracted vs actual hours. Recharts on the live database. CSV export for payroll integration.
Wellness tracking
Mood check-ins, breathing exercises, stretch reminders driven by cron. Admin dashboard for aggregate trends without exposing individual responses.
IT support tickets
Submit tickets, track status, auto-cleanup of resolved tickets via cron. Light enough to replace a shared inbox without standing up a full ticketing system.
AI assistant (optional)
Conversational assistant aware of your attendance, leave balances, expenses, team calendar, and upcoming birthdays. Powered by Groq with llama-3.3-70b-versatile by default. Disabled when GROQ_API_KEY is unset.
PDF generation throughout
Leave certificates, expense claim forms, visitor passes, GDPR exports. All produced on demand by PDFKit. A logo at public/logo.png is picked up automatically if present.
Immutable audit log
Every privileged action is recorded: SSO logins, leave approvals and accruals, GDPR exports, role changes, kiosk events. Append-only. Admin-only viewer with filters.
One-click GDPR data export
Any signed-in member can download a single portable JSON document containing every record held about them. Admins can export another member by id. Every export is itself recorded in the audit log.
Row-level security throughout
Every table enforces per-user and per-role isolation at Postgres. Authorisation is enforced twice: middleware gates by role, then RLS filters every row. Service-role access stays in trusted server contexts only.
Notifications via Resend
Email via Resend for approvals, reminders, visitor alerts. Notification settings are admin-configurable per type so you can turn off the ones you do not want.
Payroll-ready exports
Monthly export of approved timesheets, leave taken, and expenses claimed. CSV format compatible with Xero, QuickBooks, Sage. Bank statement reconciliation tooling for the accounts team.
CI and migrations
CI gate on every push. Numbered SQL migrations in supabase/migrations/ apply in order. Cron jobs ship as Vercel Cron and GitHub Actions workflows side by side.
Architecture at a glance
Single Next.js App Router application. Server components and server actions talk to Supabase directly. A small set of API routes covers the AI assistant and the cron jobs. No separate backend service.
System overview
One App Router app on Vercel, one Supabase project, Resend for email, optional Groq for the AI assistant, optional identity providers for SSO.
Request and auth flow
Authorisation is enforced twice: middleware gates by role; Postgres Row Level Security filters every row. The kiosk is the one route that bypasses middleware auth.
SSO domain routing
An admin maps email domains to providers. The login screen looks up the active connection and routes straight to the IdP instead of asking for a password.
Leave accrual cron
Monthly idempotent top-up. Each balance records how much it has accrued and when, so a manual re-run grants nothing further.
Quick start
You need a free Supabase project and a Resend API key. Groq is optional and only needed for the AI assistant. The README walks through each step end to end.
# 1. Clone git clone https://github.com/sarmakska/staff-portal.git cd staff-portal # 2. Install npm install # 3. Configure cp .env.local.example .env.local # then edit .env.local and set: # NEXT_PUBLIC_SUPABASE_URL=... # NEXT_PUBLIC_SUPABASE_ANON_KEY=... # SUPABASE_SERVICE_ROLE_KEY=... # RESEND_API_KEY=... # RESEND_FROM_EMAIL=noreply@yourdomain.com # NEXT_PUBLIC_APP_URL=http://localhost:3000 # CRON_SECRET=$(openssl rand -hex 32) # optional: # GROQ_API_KEY=... (enables the AI assistant) # 4. Run the SQL migrations # Open supabase/migrations/ and apply each file in numbered order # in the Supabase SQL Editor, or run "supabase db push" with the CLI # 5. Configure Supabase Auth URLs # Authentication then URL Configuration # Site URL: http://localhost:3000 # Redirect URLs: http://localhost:3000/auth/callback # 6. Run npm run lint && npm test npm run dev # 7. Visit http://localhost:3000 # sign up with your admin email, verify, then promote your role # to "admin" in the Supabase profiles table
Full walkthrough including Supabase auth URLs and the first admin promotion: Quick-Start wiki page.
SSO domain routing
One lookup against the sso_connections table picks OAuth or SAML at login time. Several identity providers coexist by routing on email domain.
// app/auth/login/route.ts (sketch of the SSO routing logic)
import { createServerClient } from '@/lib/supabase/server'
import { findSsoConnection } from '@/lib/actions/sso'
export async function POST(req: Request) {
const { email } = await req.json()
const domain = email.split('@')[1]?.toLowerCase()
const supabase = createServerClient()
// Look up an active connection for this domain
const connection = await findSsoConnection(domain)
if (connection?.kind === 'oauth') {
// Entra ID, Google Workspace, GitHub, GitLab
return supabase.auth.signInWithOAuth({
provider: connection.provider,
options: { redirectTo: `${process.env.NEXT_PUBLIC_APP_URL}/auth/callback` },
})
}
if (connection?.kind === 'saml') {
// SAML 2.0 via Supabase signInWithSSO
return supabase.auth.signInWithSSO({
domain,
options: { redirectTo: `${process.env.NEXT_PUBLIC_APP_URL}/auth/callback` },
})
}
// No active connection. Fall through to password login.
return Response.json({ password: true })
}Row Level Security
Authorisation is enforced twice: middleware gates routes by role, then Postgres RLS filters every row by the authenticated user and their role. This is a sample policy from the migrations.
-- supabase/migrations/002_attendance_rls.sql (sketch)
-- Authorisation is enforced twice: middleware by role, then RLS per row.
alter table attendance enable row level security;
-- Employees can see and edit their own attendance.
create policy attendance_self
on attendance
for all
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
-- Directors and admins can read all attendance rows.
create policy attendance_read_all
on attendance
for select
using (
exists (
select 1 from profiles p
where p.id = auth.uid()
and p.role in ('director', 'admin')
)
);
-- Admins can insert and update on behalf of any user.
create policy attendance_admin_write
on attendance
for all
using (
exists (
select 1 from profiles p
where p.id = auth.uid() and p.role = 'admin'
)
);Five roles
Every authenticated user has exactly one role. RLS policies are written against this set, and the admin UI surfaces the role of every account at a glance.
| Role | Access |
|---|---|
| employee | Own attendance, timesheets, leave, expenses, diary, calendar, announcements |
| reception | Employee access plus visitors, reception desk, kiosk settings |
| director | Employee access plus analytics, all timesheets (read-only), staff summary |
| accounts | Employee access plus all timesheets (read-only), expense reports, bank reconciliation |
| admin | Full access, including user management, SSO, audit log, leave allowances |
Twelve scheduled jobs
Cron endpoints under /api/cron/* are authenticated with Authorization: Bearer CRON_SECRET. Drive them with Vercel Cron, GitHub Actions, or any external scheduler.
| Job | Endpoint | Suggested schedule |
|---|---|---|
| Birthday reminders (colleagues 2 days ahead + person on the day) | /api/cron/birthday-reminder | Daily 08:00 |
| Absent reminders | /api/cron/absent-reminder | Daily 10:00 |
| Stretch reminder | /api/cron/stretch-reminder | Weekdays 14:00 |
| Forgotten clock-out reminder | /api/cron/forgotten-clockout | Daily 19:00 |
| Missing attendance report | /api/cron/missing-attendance | Daily 19:00 |
| Diary note reminders | /api/cron/diary-reminders | Every 5 minutes |
| Running-late banner reset | /api/cron/running-late-reset | Daily 00:05 |
| Time-in-office snapshot | /api/cron/time-in-office-snapshot | Daily 23:55 |
| IT ticket cleanup | /api/cron/it-ticket-cleanup | Weekly |
| Bank reconciliation sweep | /api/cron/bank-reconciliation | Daily 06:00 |
| Leave accrual | /api/cron/leave-accrual | Monthly, 1st at 00:10 UTC |
| Year-end leave carry-forward | /api/cron/year-end-rollover | Yearly, 1 Jan at 00:05 UTC |
Use cases
The kinds of organisations this is built for.
Growing agencies and studios
Teams of 15 to 50 who have outgrown spreadsheets but cannot justify £15 per user per month for BambooHR or Personio. Self-host on Vercel and Supabase for under £20 per month total.
Manufacturing and warehouses
On-site staff use the kiosk tablet to clock in with a PIN. Office staff use the web app. One database, one source of truth, no double entry.
Charities and non-profits
Donor-funded organisations cannot afford per-seat HR SaaS. MIT licensed self-host means zero software cost for unlimited employees.
Co-ops and member-owned firms
Worker co-operatives need transparent, auditable HR data. Open source means every member can read the code that runs the rota.
Schools, clinics, professional practices
Visitor management is a regulatory requirement. Pre-registration, PDF passes, audit log of every visit. Doubles as the clock-in system for non-teaching staff.
Multi-IdP organisations
Map staff to Entra ID, contractors to Google Workspace, partners to GitHub OAuth. SSO is keyed on email domain, so several IdPs coexist without a second session system.
Tech stack
StaffPortal vs alternatives
BambooHR, Personio, and Factorial are commercial SaaS HR products. StaffPortal is a self-hosted open-source platform. Rows reflect the public capabilities and pricing tiers of each.
| Capability | StaffPortal | BambooHR | Personio | Factorial |
|---|---|---|---|---|
| Attendance and timesheets | Yes, with kiosk | Yes | Yes | Yes |
| Leave management with accruals | Yes, monthly cron | Yes | Yes | Yes |
| Expenses with receipt OCR | Via Receipt Scanner | Add-on | Add-on | Add-on |
| Visitor management | Yes, with PDF passes | No | No | No |
| Public kiosk mode | Yes, PIN auth | Mobile app | Mobile app | Mobile app |
| Single sign-on (OAuth + SAML) | Yes, multi-IdP | Paid plan | Paid plan | Paid plan |
| Immutable audit log | Yes | Yes | Yes | Yes |
| GDPR data portability export | One-click JSON | Manual ticket | Yes | Yes |
| Self-host on own infra | Yes, Vercel + Supabase | No | No | No |
| Per-user pricing | None | £6-£12 / user / mo | £8-£14 / user / mo | £4-£10 / user / mo |
| License | MIT | Commercial | Commercial | Commercial |
An honest limitations list
Where StaffPortal is the wrong choice, so you can rule it in or out quickly.
Not multi-tenant
Each deployment serves one organisation. By design. If you want to run StaffPortal for several clients you run several deployments. There is no per-tenant routing layer.
No certified payroll engine
Timesheet and leave exports are payroll-ready CSV, not a full payroll engine. HMRC PAYE submission, RTI, P11D are out of scope. Pair with a dedicated payroll product.
No enterprise HRIS integrations out of the box
Workday, SAP SuccessFactors, Oracle HCM are not on the integration roadmap. The schema is open Postgres; build the connector you need.
You must operate Supabase and Vercel
If you do not want to manage a Supabase project and a Vercel deployment, buy a hosted HR product. The self-hosting itself is light, but it is not zero.
Single-tenant by deployment, no SCIM provisioning
Users are created via signup, SSO bootstrap, or admin invite. SCIM provisioning from your IdP is on the roadmap but not shipped.
the AI assistant depends on a third-party LLM by default
The bundled the AI assistant configuration calls Groq. Disable it by unsetting GROQ_API_KEY, or swap to a local OpenAI-compatible endpoint in app/api/chat/route.ts.
Wiki documentation
Nine wiki pages covering the high-level overview, install, architecture, SSO, leave accruals, GDPR export, kiosk mode, audit log, and roadmap.
Full index: staff-portal wiki home.
Frequently asked
Can I actually run this myself?+
Yes. The stack is Next.js 16 on Vercel and Supabase for Postgres, Auth, and Storage. Both have generous free tiers. The README walks you through clone, install, migrations, env vars, first admin. There is no separate backend service to operate. If you can manage a Supabase project and a Vercel deployment, you can run StaffPortal.
How does SSO work with several identity providers?+
Each email domain maps to one provider in the sso_connections table. When a member enters an email, the login screen looks up the active connection for that domain and routes to Entra ID, Google, GitHub, GitLab via signInWithOAuth, or to a SAML 2.0 connection via signInWithSSO. The provider apps and SAML metadata are configured in the Supabase dashboard. First-time SSO sign-ins bootstrap a profile and the standard leave balances, and every SSO login is written to the audit log.
How do leave accruals work?+
Each balance can carry a monthly accrual_rate. A cron job at /api/cron/leave-accrual runs on the first of each month and tops every accruing balance up by the elapsed months times its rate, capped at the configured entitlement. Idempotent: a manual re-run the same day grants nothing further, because each balance records how much it has accrued and when. Admins and accounts staff can preview and run on demand under Admin then Leave Accruals.
What is in the GDPR export?+
A single portable JSON document containing the member's profile, attendance, leave, expenses, diary, visitors, feedback, complaints, and the audit events attributed to them. Members export their own data from Settings then Privacy and data. Admins can export another member by calling /api/gdpr/export?userId=<id>. Every export is itself written to the audit log so requests are traceable.
Is the kiosk actually a separate app?+
No. /kiosk is the one route that bypasses login. It uses scoped server actions for clock in / out and visitor check-in only, authenticated with a per-user kiosk PIN. The rest of the app is gated by middleware that requires a valid Supabase session. Mounting a tablet in kiosk mode is a deployment choice, not a separate codebase.
Do I have to use the AI assistant?+
No. the AI assistant is optional and disabled when GROQ_API_KEY is unset. When enabled it has read access to your attendance, leave balances, contracted vs actual hours, team leave, who is in the office, upcoming birthdays, recent expenses, active polls, and announcements. The personality and system prompt are in app/api/chat/route.ts and the provider is OpenAI-compatible, so swap to any other LLM in a few lines.
How is data isolated between users and roles?+
Twice. The middleware gates routes by role, redirecting unauthenticated users to login and blocking employees from admin paths. Then Postgres Row Level Security filters every row by the authenticated user and their role. Even if route logic has a bug, Postgres refuses cross-user reads. The service-role key is used only in trusted server contexts (cron handlers, admin operations) and never ships to the client.
What does the audit log capture?+
Every privileged action. SSO logins (success and failure), leave approvals and withdrawals, leave accrual runs, role changes, GDPR exports, kiosk check-ins, visitor check-ins, manual data edits in the admin UI. Append-only, admin-only viewer with filters. This is the system of record when someone asks "who approved this leave and when".
How does the live time-in-office counter work?+
Each clock_event row carries a timestamp. The dashboard reads the most recent clock-in for the current user, subtracts any logged break intervals, and ticks the result every second client-side. There is no separate timer table to keep honest, the counter is derived state and cannot drift from the events.
What is the reception live-status board?+
A live view at /reception that buckets every employee into one of five states: In office, Working from home, On leave, Running late, Clocked out. State is derived from clock_events, the WFH flag, approved leave_requests and pre-logged running-late entries. Refreshed via Supabase realtime, no polling.
How does multi-step leave approval work?+
Each leave request can require up to three approvers in priority order. The first approver is emailed when the request is submitted. On their decision the chain advances to the next approver, or terminates if rejected. Every step writes to the audit log. The PDF leave certificate is generated by PDFKit and emailed to the employee on final approval.
What happens at year end with unused leave?+
A cron job at /api/cron/year-end-rollover runs once at 00:05 on 1 January. For every employee, unused entitlement is carried forward up to the configured cap (per leave type). Idempotent: re-running the job grants nothing further because each balance records when it was last rolled. The action is written to the audit log.
How is the QR-coded visitor flow set up?+
A host pre-registers a visitor in the portal. The system generates a QR-coded reference and emails the visitor a printable pass. On arrival they scan the QR at the kiosk to check themselves in. The host is emailed and the reception board updates. Every step is in the audit log.
Can I export an Excel timesheet?+
Yes. Monthly timesheet export is built on ExcelJS and produces a workbook with one sheet per employee plus a summary sheet. The same export feeds the bank statement reconciliation tool, which matches deposits to approved expense claims for the accounts team.
Use it. Fork it. Run it.
MIT licensed. Pull requests welcome, especially around mobile (React Native / Expo), Slack and Teams integrations, payroll exports for Xero and QuickBooks, shift scheduling, and i18n.
Run your own HR platform
Clone the repo, point it at your Supabase project, deploy to Vercel. Your staff data stays on infrastructure you control.