# Team leave planning, beautifully simple An open-source leave management app for small and mid-size teams. Self-hosted, customizable, and ready in minutes. [ View on GitHub ](https://github.com/igornast/who-is-out-of-office) - Self-hosted - AGPL-3.0 License - Built for teams Features ## Everything your team needs to manage time off No spreadsheets, no email chains. Just a clean interface your entire team will actually use. ### Team calendar See who's out at a glance. Weekly and monthly views with leave-type color coding and emoji icons. ### Two-factor authentication Optional TOTP two-factor, with backup codes for when you lose your phone. Your secret is encrypted at rest and only decrypted to verify a code. ### Role-based access Three roles out of the box: Admin, Manager, and Employee. Each sees only what they need. ### Dark mode & themes Four color palettes (Warm Teal, Sage Green, Sunset Warm, Soft Lavender) plus light, dark, and system theme. ### Mobile-ready Fully responsive layout with bottom navigation, swipe-friendly calendar, and optimized touch targets. ### Public holidays Auto-import public holidays per country via Nager.Date API. Supports multiple country calendars simultaneously. ### One-click approvals Managers approve or decline requests from the dashboard or directly from a Slack notification. Personalization ## Make it yours Choose from four hand-crafted color palettes and switch between light, dark, or system themes. Every user picks their own preference. ### Warm Teal Default palette ### Sage Green Earthy tones ### Sunset Warm Coral accent ### Soft Lavender Cool & calm Integrations ## Connects with the tools you already use Notifications, calendar sync, and automatic holiday data, all out of the box. ### Slack Leave request notifications, approval actions, and weekly team digests, delivered straight to your Slack channels. Built-in ### iCal feeds Subscribe to team absences from Google Calendar, Apple Calendar, Outlook, or any app that supports iCal. Built-in ### Public Holidays API Automatic import of country-specific public holidays via Nager.Date. Supports DE, FR, NL, and 100+ more countries. Built-in Open Source ## Free forever. AGPL-3.0 licensed. Who's OOO is a Symfony 7.4 / PHP 8.5 application backed by MySQL 8.4, fully open source under AGPL-3.0. Deploy on [fortrabbit](https://www.fortrabbit.com/pricing?php=xs&database=xs&storage=xs&traffic=xs&backups=xs&jobs=xs&key-value-store=off) for €4.5/month or run it anywhere with Docker Compose. Audit the code, extend it to fit your workflow. No vendor lock-in, no subscription fees. Use it freely. If you modify and redistribute, share your changes under the same license. [ Star on GitHub ](https://github.com/igornast/who-is-out-of-office) Build in public ## I build Who's OOO in the open When you self-host an app, you're trusting whoever built it to run on your own server. So here's me. I build Who's OOO live on YouTube. If you want to see who's behind the code before you deploy it, this is the most honest way I can show you. Watch a stream, ask questions, follow along if it's useful. [ Subscribe @igor\_koneko ](https://www.youtube.com/@igor_koneko) ![Creator of the Who's Out of Office application](https://whoisooo.app/assets/images/igor-nast.jpg) [ ](https://www.linkedin.com/in/igor-nast/) [ ](https://github.com/igornast) Need help getting started? ## Setup & configuration service Hi, I'm Igor, the creator of Who's OOO. I built this as a personal open-source project, and we use it daily at **fortrabbit** to manage our own team's leaves. I offer a one-time paid setup service to help you get up and running quickly, with no ongoing fees or subscriptions. - Server deployment and initial configuration - Slack workspace integration setup - Custom leave types, holiday calendars, and team onboarding - Theme customization and branding to match your company [ Get in touch ](mailto:igor@whoisooo.app) Trusted by teams at - [·fortrabbit](https://www.fortrabbit.com) ## Ready to simplify leave planning? Deploy Who's OOO on fortrabbit or your own server and have your team set up in minutes. [ View on GitHub ](https://github.com/igornast/who-is-out-of-office) ## Featured on [ ![Who's OOO - Featured on Startup Fame](https://startupfa.me/badges/featured/default-small-rounded.webp) ](https://startupfa.me/s/whos-ooo?utm_source=www.whoisooo.app) --- Blog # Writing Thoughts on building Who's OOO, Symfony, and open-source development. [\#tech](/blog/tag/tech/) [\#comparison](/blog/tag/comparison/) [\#news](/blog/tag/news/) July 11, 2026 ## [ TimeOff.Management vs Who's OOO: Open Source Leave Management Compared ](https://whoisooo.app/blog/whos-ooo-vs-timeoff-management) Comparing TimeOff.Management vs Who's OOO? Full open source leave management comparison: stack, Slack sync, LDAP, API, and which self-hosted tool fits your team. [\#comparison](/blog/tag/comparison/) [ Read more — TimeOff.Management vs Who's OOO: Open Source Leave Management Compared ](https://whoisooo.app/blog/whos-ooo-vs-timeoff-management) June 7, 2026 ## [ Who's OOO 0.3.0: Feed Customization, Org Chart, and Public Holidays ](https://whoisooo.app/blog/whos-ooo-0-3-0) A short recap of what shipped in Who's OOO 0.3.0: a customizable iCal feed, a better org chart, public holidays in the request calendar, and an in-app news feed. [\#tech](/blog/tag/tech/) [ Read more — Who's OOO 0.3.0: Feed Customization, Org Chart, and Public Holidays ](https://whoisooo.app/blog/whos-ooo-0-3-0) May 9, 2026 ## [ HR SaaS Vendor Risk: When Your Leave Management Tool Disappears ](https://whoisooo.app/blog/hr-saas-vendor-risk) Real stories of HR SaaS tools that shut down, got acquired, or hiked prices, and what they tell you about the cost of depending on a vendor for leave management. [\#news](/blog/tag/news/) [ Read more — HR SaaS Vendor Risk: When Your Leave Management Tool Disappears ](https://whoisooo.app/blog/hr-saas-vendor-risk) April 23, 2026 ## [ Adding Two-Factor Authentication to Who's OOO ](https://whoisooo.app/blog/adding-2fa-to-whosooo) How I added optional 2FA to Who's OOO: encrypted TOTP secrets, Argon2id backup codes, rate limiting, and no open-redirect traps. [\#tech](/blog/tag/tech/) [ Read more — Adding Two-Factor Authentication to Who's OOO ](https://whoisooo.app/blog/adding-2fa-to-whosooo) April 11, 2026 ## [ Google Sheets Leave Tracker: A Free Self-Hosted Alternative ](https://whoisooo.app/blog/leave-tracker-spreadsheet-alternative) Outgrown your Google Sheets leave tracker? Here's a free self-hosted alternative with approvals, Slack sync, and shared team calendars. [\#comparison](/blog/tag/comparison/) [ Read more — Google Sheets Leave Tracker: A Free Self-Hosted Alternative ](https://whoisooo.app/blog/leave-tracker-spreadsheet-alternative) April 4, 2026 ## [ Self-Hosted vs SaaS Leave Management: Open Source Tools Compared ](https://whoisooo.app/blog/self-hosted-vs-saas-leave-management) Comparing self-hosted leave management to SaaS? See the real cost difference, GDPR implications, and which open source absence tracker fits your team. [\#comparison](/blog/tag/comparison/) [ Read more — Self-Hosted vs SaaS Leave Management: Open Source Tools Compared ](https://whoisooo.app/blog/self-hosted-vs-saas-leave-management) March 31, 2026 ## [ Why I Built Who's OOO ](https://whoisooo.app/blog/why-i-built-whosooo) Every company I worked at tracked leave differently. Spreadsheets, calendar invites, Slack messages that got buried. At some point I got tired of it and built something. [\#news](/blog/tag/news/) [ Read more — Why I Built Who's OOO ](https://whoisooo.app/blog/why-i-built-whosooo) --- # Adding Two-Factor Authentication to Who's OOO > How I added optional 2FA to Who's OOO: encrypted TOTP secrets, Argon2id backup codes, rate limiting, and no open-redirect traps. A self-hosted leave planner doesn't look like an obvious target. No payment data, no source code, no production secrets. But there is a list of who's away on which dates and which manager approves what, the kind of information that matters to anyone running a small or medium company. Enough reason to take accounts security seriously. Who's OOO now ships with optional two-factor authentication. Users turn it on from their account settings, scan a QR code with any TOTP app, save a set of backup codes, and require a second factor at every login from then on. Nothing surprising from the user perspective. The interesting parts are behind the form. ## 2fa bundle The Symfony ecosystem already has [`scheb/2fa-bundle`](https://github.com/scheb/2fa), which handles the authentication flow, integrates with the symfony firewall, and supports TOTP, email codes, backup codes, and trusted devices. No need for custom solution here. The interesting work was integrating it without leaving the secrets in plaintext. ## TOTP secrets at rest The default scheb integration stores the TOTP secret as a plain string on the user entity. That works, but anyone with read access to the database has everything they need to clone every user's authenticator. A leaked backup, a forgotten dump on a developer's laptop, an SQL injection somewhere down the line. ![Two-factor authentication setup screen in Who's OOO with a QR code and a verification form for password and 6-digit TOTP code](/assets/images/blog/2fa/2fa-setup-qr.png) I wanted to keep secrets encrypted in the database and only decrypt them at the moment of verification. `sodium_crypto_secretbox` is the straightforward libsodium option: authenticated symmetric encryption with a 32-byte key. ```php class TotpSecretEncryptor { private readonly string $key; public function __construct( #[Autowire(env: 'TOTP_ENCRYPTION_KEY')] string $base64Key, ) { $decoded = base64_decode($base64Key, true); if (false === $decoded || SODIUM_CRYPTO_SECRETBOX_KEYBYTES !== strlen($decoded)) { throw new \InvalidArgumentException('Invalid TOTP encryption key: must be a base64-encoded 32-byte key'); } $this->key = $decoded; } public function encrypt(string $plainSecret): string { $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); $ciphertext = sodium_crypto_secretbox($plainSecret, $nonce, $this->key); return base64_encode($nonce.$ciphertext); } public function decrypt(string $encryptedSecret): string { // ... } } ``` 2FA is off by default, and the encryptor is registered as a lazy service. A fresh self-hosted instance runs fine without `TOTP_ENCRYPTION_KEY` configured; the service only gets instantiated once someone actually turns the feature on. At that point the constructor refuses to start if the key is missing or not exactly 32 bytes once decoded. I'd rather the container fail loudly the moment 2FA is used than fall back to a weak default. The key itself is generated once with `openssl rand -base64 32` and stored wherever the rest of your secrets live. ## Decorating the authenticator The interesting part is wiring the encryption into scheb without forking it. The bundle exposes a `TotpAuthenticatorInterface` and reads the secret directly from the user entity. If you put encrypted bytes there, the bundle has no idea what to do with them. A decorator fixes it. `EncryptedTotpAuthenticator` wraps the bundle's authenticator and decrypts the secret onto a transient field on the user just before the inner authenticator reads it: ![Sequence diagram showing how EncryptedTotpAuthenticator decrypts the TOTP secret before delegating to the scheb bundle's inner authenticator](/assets/images/blog/2fa/2fa-decorator-flow.png) ```php class EncryptedTotpAuthenticator implements TotpAuthenticatorInterface { public function __construct( private readonly TotpAuthenticatorInterface $inner, private readonly TotpSecretEncryptor $encryptor, ) { } public function checkCode(object $user, string $code): bool { $this->decryptSecretIfNeeded($user); return $this->inner->checkCode($user, $code); } private function decryptSecretIfNeeded(object $user): void { if (!$user instanceof User || !$user->isTwoFactorEnabled || null === $user->totpSecret) { return; } $user->setDecryptedTotpSecret($this->encryptor->decrypt($user->totpSecret)); } } ``` The user entity has a separate non-persisted property for the decrypted secret, and `eraseCredentials()` clears it once the request is done. The bundle never knows or cares that the secret was encrypted at rest. From its perspective, it's reading a normal in-memory string. Decoration doing what it's good at. ## Backup codes Backup codes is the recovery option when someone's phone is in a drawer at home or wiped between devices. They need to be unguessable and stored so a database leak doesn't immediately compromise them. Eight codes per user, generated from a 31-character alphabet that drops the visually ambiguous ones (`0`, `1`, `i`, `l`, `o`): ```php private const ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789'; ``` Each code is two four-character halves separated by a dash, generated with `random_int`. Storage uses Argon2id via PHP's native password hasher, the same type as the password column. `password_verify` is constant-time and handles the comparison: ```php class BackupCodeHasher { private readonly NativePasswordHasher $hasher; public function __construct() { $this->hasher = new NativePasswordHasher(opsLimit: 4, memLimit: 9_437_184, algorithm: PASSWORD_ARGON2ID); } public function hash(string $code): string { return $this->hasher->hash($code); } public function verify(string $hashedCode, string $plainCode): bool { return $this->hasher->verify($hashedCode, $plainCode); } } ``` ![Backup codes recovery screen in Who's OOO showing eight one-time codes displayed after enabling two-factor authentication](/assets/images/blog/2fa/2fa-backup-codes.png) Codes are shown to the user exactly once, on the screen right after enabling 2FA. After that, only the hashes stay. ## Re-authentication for privileged actions There's a category of situations where someone wanders away from their laptop, and a coworker walks past, and ten seconds later 2FA has been disabled "as a joke." Or worse, an attacker with a compromised session can quietly turn off the second factor before doing anything noisy. Enabling, disabling, and regenerating backup codes all require the user to type their password again. Disable additionally requires a current TOTP code. Both checks happen inside the controller before anything is written: ```php if (!$this->passwordHasher->isPasswordValid($user, $dto->password)) { $this->addFlash('danger', 'settings.two_factor.disable.error.invalid_password'); return $this->redirectToRoute('app_two_factor_disable'); } // ... TOTP check follows ``` Small friction. Worth it. ## Rate limiting Symfony's rate limiter does the job here: ```yaml framework: rate_limiter: two_factor_login: policy: fixed_window limit: 5 interval: "15 minutes" ``` A subscriber listens for `TwoFactorAuthenticationEvents::ATTEMPT`, consumes a token keyed by user identifier, and throws `TooManyLoginAttemptsAuthenticationException` when the bucket is empty. The same limiter is applied to the setup and disable controllers, so brute-forcing the password-plus-code prompt is bounded too. On a successful authentication the bucket resets. Five attempts per fifteen minutes is intentionally tight. A real user typing a code from their phone won't hit it. Anything automated will. ## Avoiding the open redirect One detail that's easy to get wrong is where the user lands after the second factor is verified. The Scheb package supports a `default_target_path` option, but it also honors whatever target was on the original request. ![Login challenge page at /2fa in Who's OOO asking for a 6-digit TOTP code after a successful password step](/assets/images/blog/2fa/2fa-login-challenge.png) The firewall config pins it: ```yaml two_factor: auth_form_path: 2fa_login check_path: 2fa_login_check default_target_path: /app/dashboard always_use_default_target_path: true ``` `always_use_default_target_path: true` tells the bundle to ignore the original target and always land on the dashboard. Less confusing for users. ## What this doesn't do A few things I didn't ship. WebAuthn and hardware keys. They're a better second factor than TOTP, but TOTP covers most users with very little setup friction. Hardware keys are a separate piece of work. Forced 2FA across an org. An admin can't currently require everyone to enable it. Reasonable for the next iteration, but I wanted the opt-in flow stable first. Trusted devices. The 2fa package supports them, but Who's OOO is the kind of app you log into rarely, and skipping the second factor "for thirty days" felt like the wrong default for a security feature, at least today. ## In closing 2FA isn't the headline feature anyone picks a leave planner for. Nobody's switching from a spreadsheet because Who's OOO supports TOTP. But teams self-hosting it care that the data stays secure, and for them proper account verification is part of the desired flow. Who's OOO is open source and free to self-host. The 2FA implementation is on [GitHub](https://github.com/igornast/who-is-out-of-office) along with the rest of the code. If the project is useful to you, give it a star, or consider sponsoring the development if you want to support it. --- # HR SaaS Vendor Risk: When Your Leave Management Tool Disappears > Real stories of HR SaaS tools that shut down, got acquired, or hiked prices, and what they tell you about the cost of depending on a vendor for leave management. I keep coming back to vendor risk in the self-hosted vs SaaS conversation, and the Lattice news from late 2025 is exactly why. In November 2025, Lattice told its customers it was sunsetting its HRIS and Payroll products. Payroll access ends on 31 March 2026. HRIS access ends on 31 July 2026. Lattice is a well-funded, recognised brand in HR tech, valued at $3 billion in 2022. None of that mattered to the customers who suddenly had a few months to migrate their employee records elsewhere. This is the part of the SaaS conversation nobody likes to lead with. When you pick a tool to track who's on holiday next week, you're also signing up for the vendor's roadmap, pricing decisions, and continued existence. That risk is invisible until the day it isn't. I want to walk through some concrete examples from the HR space, because the pattern is more common than people think. ## The Lattice sunset, in context Lattice didn't shut down. They scaled back. Performance management is staying; the HRIS and Payroll products are going away, and existing customers are being pointed at Workday as a replacement. There's [a writeup from Calamari](https://www.calamari.io/blog/lattice-hris-is-shutting-down-how-to-migrate-without-the-enterprise-price-tag) covering what affected teams need to do, and [Hibob has a piece](https://www.hibob.com/blog/lattice-competitors-hris-alternatives/) on alternatives. Look at the timing. The HRIS launched in 2024. Less than two years later it's being unwound. If you onboarded onto Lattice HRIS as a small company in early 2024, you spent that time configuring fields, importing employees, and training people on it. Now you're migrating again. None of that work transfers cleanly. That's the cost the pricing page never shows. ## Zenefits, and the limits of funding as a moat Zenefits is the flagship cautionary tale. Founded in 2013, [valued at $4.5 billion in May 2015 after a $500M round](https://techcrunch.com/2015/05/06/zenefits-rising-hrs-hottest-startup-just-raised-500-million-at-a-4-5-billion-valuation/) led by Fidelity and TPG, with Andreessen Horowitz participating. By 2017 the new CEO had laid off 45% of the workforce. In February 2022 it was sold to TriNet, in what most observers described as a fire sale relative to its peak. The story doesn't end with the acquisition. In December 2024, TriNet announced it was [sunsetting the standalone Zenefits HRIS plan](https://www.outsail.co/post/trinet-to-lay-off-zenefits-team-and-sunset-standalone-hris-option). The product is now branded TriNet HR Plus, and customers on the original Zenefits offering had to migrate to a more expensive enterprise tier or leave the platform. $500 million in funding, a $4 billion valuation, a major-name acquirer. None of it guaranteed continuity for the small businesses that built their HR processes around the product. ## UKG Workforce Central: even the enterprise stack ages out If you assume the small startups are the risky bets and the giants are safe, the UKG story shows the same pattern at enterprise scale. Kronos and Ultimate Software merged into UKG in April 2020. After the merger, UKG announced that Workforce Central, the legacy Kronos product used by enormous organisations including hospital systems and large employers, would be discontinued. The cloud-hosted environment was [shut down on 31 December 2025](https://www.healthcareitleaders.com/blog/ukg-workforce-central-wfc-end-of-life/). On-premises support ends on 31 March 2027. Customers can migrate to UKG Pro Workforce Management, which is a different product with different ergonomics and a different configuration model. It's not an upgrade. It's a forced rewrite of how your organisation runs payroll and time tracking. Size doesn't insulate you from this kind of decision; it just means more of your people are affected when it lands. ## Acquisitions: the product survives, the product you chose doesn't Not every story is a shutdown. Sometimes the product keeps running, but it stops being the thing you originally signed up for. CakeHR was a small, focused leave and HR tool until [Sage acquired it in November 2019](https://www.sage.com/investors/investor-downloads/press-releases/2019/11/sage-announces-acquisition-of-cakehr/) and rebranded it to Sage HR a year later. It still exists. The roadmap, pricing, and strategic priorities now belong to a multinational accounting software company. Timetastic, which I mentioned in [the self-hosted vs SaaS post](/blog/self-hosted-vs-saas-leave-management) as an example of a mature product that's a reasonable choice, was [acquired by The Citation Group in May 2023](https://thecitationgroup.com/news_article/the-citation-group-acquires-timetastic-global-absence-management-software-for-small-and-medium-sized-businesses/). The pricing today is $1.50 per user per month on Standard and $2.50 on Pro, broadly in line with where it was, so nothing dramatic has visibly changed for customers. But the company that owns the roadmap is now a compliance and HR services group, not the original team. Namely raised over $200 million from Sequoia, True Ventures, and GGV Capital, with a $500 million valuation in 2018. In September 2022 it was [acquired by PrismHR / Vensure](https://www.prismhr.com/press-releases/namely-merges-with-vensure-employer-services-prismhr/), a private equity-backed business. Reviews after the acquisition consistently describe a product that became more rigid and more focused on margin. Humi, a Canadian HR platform with a Series B behind it, was [acquired by Australian company Employment Hero in January 2025](https://betakit.com/humi-acquired-by-australian-hr-software-company-employment-hero/). The Canadian customers who picked Humi for its local focus are now part of an Australian parent's strategy. The brand is being phased into Employment Hero, retained for existing customers and Quebec. Fond is a small data point with a layered ending. Y Combinator-backed, [acquired by Reward Gateway in March 2023](https://www.rewardgateway.com/press-releases/reward-gateway-acquires-fond). Reward Gateway itself was acquired by Edenred two months later. If you were a Fond customer in early 2023, you became a Reward Gateway customer, and then an Edenred customer, inside a six-month window. ## Price hikes are vendor risk too Shutdowns get the attention, but price changes are far more common and just as disruptive when you're locked in. BambooHR restructured its pricing in 2024. Reports from customers and review sites pointed to roughly 30% increases for affected tiers, plus a $250 minimum monthly charge that hits teams under 25 employees particularly hard. Features that used to be included (payroll, time tracking, benefits administration) moved to paid add-ons. Gusto raised the base fee on its Simple plan from $40 to $49 in March 2026, an increase of around 23%, with no new features attached. Their EOR plan went from $599 to $699 in the same round. Plus and Premium tiers were left alone, but for the small businesses on Simple, the math changed overnight. This is the late-stage SaaS pricing pattern. Once a tool has enough customers and enough switching cost, the lever to pull is price. There's nothing you can do about it except migrate, and migration is exactly what the pricing model is betting you won't do. ## What this means for picking a tool To be fair: SaaS is the right call for plenty of teams. If you don't have a developer who can run Docker, or if you need mobile apps and SSO and integrations from day one, a mature SaaS product is the pragmatic choice. The trade-offs in [the previous post](/blog/self-hosted-vs-saas-leave-management) still hold. What I am saying is that vendor risk is a real category of cost, and it's the one most teams underweight when comparing options. The pricing page shows you a per-user-per-month number. It doesn't show you the probability that in three years you'll be migrating, on someone else's timeline, to a product you didn't choose. Lattice, Zenefits, UKG, CakeHR, Timetastic, Namely, Humi, Fond. These aren't outliers. They're the normal lifecycle of HR SaaS. Self-hosting is one way to take that specific category of risk off the table. Nobody can sunset your deployment. Nobody can raise your prices on a Tuesday. You're responsible for keeping the thing running, and that's a real cost. But it's a cost you control. ## Vendor risk is a line item When you depend on a vendor, you depend on their judgement, their funding situation, their next acquirer, and their pricing roadmap. Most of the time none of this matters. Until it does, and then it matters all at once. Who's OOO is open source and free to self-host. The source is on [GitHub](https://github.com/igornast/who-is-out-of-office), and the documentation walks you through getting it running with Docker. If you're evaluating options for your team, run the numbers over three years, and factor vendor risk in honestly. It's a cost like any other. ## Related posts - [Self-hosted vs SaaS leave management: how to decide](/blog/self-hosted-vs-saas-leave-management) --- # Google Sheets Leave Tracker: A Free Self-Hosted Alternative > Outgrown your Google Sheets leave tracker? Here's a free self-hosted alternative with approvals, Slack sync, and shared team calendars. Every company I've worked at handled leave tracking differently. Spreadsheets, a shared calendar, a "post in #general before you go" convention. I've seen all of them. The shared Google Sheet is usually where it starts. It's free, everyone already has it, and a junior dev can set one up in ten minutes. Six months later something breaks. A leave request gets missed, two people book the same week, or a colleague keeps getting DMs because nothing tells anyone they're away. At that point the spreadsheet isn't solving the problem anymore. It's just a place where problems happen. This post is about what that looks like, and what I ended up building instead. ## Where the Google Sheets leave tracker stops working Most of these I've either seen myself or had described to me almost word-for-word by other engineering managers. Someone submits a request and the manager never notices. There's no notification when a row is added: no flag, no alert, no email. The leave happens anyway. The manager finds out on Monday when they ask where their teammate is. Two people book the same week because nobody checks the sheet before requesting. The conflict only surfaces when both are gone and the team is suddenly short-staffed in the middle of a release. A row gets deleted or overwritten. Anyone with edit access can change anything: no audit trail, no approval-locked record. Someone edits a date field by mistake and a week later you're trying to reconstruct what happened from a history that doesn't exist. No Slack status, no calendar block. Someone on PTO keeps getting DMs from people expecting a reply. The only source of truth is a sheet that nobody remembers to open. Public holidays get counted inconsistently. Different rows treat them differently (some subtract from the annual allowance, some don't) because the sheet has no concept of a rule. Remote teams add timezone confusion on top. Half-days are listed as "AM" or "PM" with no standard. Dates are ambiguous. The sheet doesn't know what timezone anyone is in, and neither does the person reading it. None of these are hypothetical. Every team I've talked to that outgrew a spreadsheet can name one of these specifically. ## The part no spreadsheet can do The single feature that made me stop refining another sheet and actually build something was Slack status sync. When a colleague is on leave, their status shows "On leave" to anyone who DMs or @-mentions them. It's ambient, so nobody has to check anything. On the approved leave days the status gets set automatically, and it gets cleared at midnight on the last day. A shared calendar can get you partway there. A spreadsheet can't do it at all. That's the piece I'd want in any tool I used, and it's the reason the post you're reading exists. ## How Who's OOO handles it The workflow in Who's OOO looks like this: 1. An employee submits a leave request through the web UI. 2. The manager gets a Slack notification with approve and reject buttons, no browser required. 3. On approval, the team calendar updates automatically. 4. On the approved leave days, the employee's Slack status is set to "On leave" and cleared at midnight on the last day. 5. A weekly digest goes out every Monday showing who's out that week. ![Slack leave approval notification from the Absences Bot showing a new sick leave request with Approve and Reject buttons](/assets/images/blog/leave-tracker-spreadsheet-alternative/slack-approval.png) That's the approval message managers get. One tap, no context switch, no browser tab. The moment a request is approved, the shared team calendar updates automatically. It shows everyone at once: current month, filterable by leave type or employee, and it's the same view everyone on the team sees. Nothing to refresh, nothing to merge. ![Team Calendar view in Who's OOO showing approved leaves across multiple team members for April 2026 with colour-coded entries per employee](/assets/images/blog/leave-tracker-spreadsheet-alternative/team-calendar-view.png) The Monday digest is the other half of the pattern: a single message posted to your team channel listing everyone out that week, grouped by leave type and with date ranges. It lands in the same place people already read announcements, so nobody has to open another tool. ![Slack weekly digest message from Absences Bot listing team members out this week with their leave types and date ranges](/assets/images/blog/leave-tracker-spreadsheet-alternative/weekly-digest.png) Nobody has to remember to check anything. The information comes to the team. Public holidays are imported from the Nager.Date API: 100+ countries, no manual entry. The iCal feed means Google Calendar, Outlook, and Apple Calendar pick up absences automatically once a team member subscribes. ## A note on the stack I built [Who's OOO](https://github.com/igornast/who-is-out-of-office) with Symfony 7.4 and PHP 8.5 because that's what I've been working with since 2015 and I wanted something I could maintain without friction. The entire UI runs on EasyAdmin, including the employee-facing parts, which is unusual, but it let me ship faster than hand-rolling Symfony Forms. Data access goes through Doctrine with XML mapping, tests run on Pest with 95%+ coverage, and PHPStan sits at Level 8. Nothing exotic. The point was to have a tool I could actually run in production without constant attention. That also matters for the "is it really a developer afternoon?" question. It is, but only because Symfony deploys cleanly and there's nothing special about the setup: a standard PHP app with MySQL. Any fortrabbit instance or Docker host will take it without fuss. ## Cost For most absence management tools, cost is a real objection. £1–4 per user per month adds up for a team of 20 before you've done anything. Who's OOO is free to self-host, AGPL-3.0 licensed. Deploy on [fortrabbit](https://www.fortrabbit.com/pricing?php=xs&database=xs&storage=xs&traffic=xs&backups=xs&jobs=xs&key-value-store=off) for €4.5/month or run it anywhere with Docker Compose. If you already have a server, adding it costs nothing. ## What it does and doesn't do It covers leave request submission, manager approval, configurable leave types (annual, sick, parental, unpaid, plus custom ones), per-type toggles for whether a type requires approval or tracks balance, annual allowances with per-employee overrides, and roles for Admin, Manager, and Employee. The Slack integration does approvals, weekly digests, and status sync. Public holidays auto-import for 100+ countries. Google Calendar and Outlook pick up absences through an iCal feed. Dark mode and four color palettes are in the box. There are things it doesn't do, and I want to be honest about them. No mobile app. No multi-level approvals. No SSO. No REST API. No accrual rules or carry-over calculations. If any of those are hard requirements, Timetastic or Calamari are worth evaluating - they're good tools. But they will cost you much more money. For teams that want the spreadsheet replaced with an approval flow, Slack integration, and a shared calendar that actually works, Who's OOO covers the full list. ## Migrating off a Google Sheets leave tracker The migration is simpler than it sounds. Export the existing leave history from the spreadsheet and keep it as a reference. Add employees to Who's OOO and configure their leave type allowances. Import public holidays for your country in one click. Share the URL with your team. That's it. The [installation guide](/docs) walks through deploying on fortrabbit or with Docker. ## Common questions _Is Who's OOO really free?_ Yes. AGPL-3.0, self-hosted. You pay for the VPS, or nothing if you already have one. No per-user fees, no trial period, no gated features. _Does it integrate with Google Calendar?_ Yes. It provides an iCal feed that Google Calendar, Outlook, and Apple Calendar can subscribe to. Once subscribed, absences appear automatically. _What if I need something more than leave tracking?_ If you need payroll, recruitment, or performance reviews, OrangeHRM or a full HRIS is the right call. Who's OOO is deliberately focused on absence management only. I didn't want to build another HR suite. ## In closing Who's OOO is what I wanted every company I've worked at to have: something honest, self-hosted, free to run, and boring enough to trust. The full [source is on GitHub](https://github.com/igornast/who-is-out-of-office). If you end up deploying it, I'd like to hear how it goes. --- # Self-Hosted vs SaaS Leave Management: Open Source Tools Compared > Comparing self-hosted leave management to SaaS? See the real cost difference, GDPR implications, and which open source absence tracker fits your team. Every time I've looked at leave management SaaS, the pricing page shows something like $1.50 per user per month. It sounds reasonable. Then I do the math for a 30-person team over three years and the number stops looking reasonable. The alternative is self-hosted leave management: running open source software on your own server, where the data stays yours and the cost doesn't compound. But that alternative has its own cost. Here's how I think about the decision. ## The honest case for SaaS SaaS wins on time-to-running. You sign up, invite your team, and the thing works in an afternoon with zero infrastructure. If you don't have a developer on the team, or if spinning up a Docker container is a blocker, SaaS is the right call. Don't fight the constraints you actually have. Tools like Timetastic, Personio, and BambooHR are mature products and genuinely good choices if the constraints fit. They have mobile apps, SSO, and integrations that take years to build. If any of those are requirements today, a well-established SaaS product likely covers them out of the box. The cost is twofold: money, ongoing and per-user, and trust. Every SaaS tool handling employee data is a data processor under GDPR. Under GDPR Article 28, every SaaS tool processing employee data requires a signed Data Processing Agreement and an entry in your Article 30 records. When you self-host, that processing stays internal, so no DPA and no third-party sub-processor entry are required. You need to trust their security practices. You accept that their pricing can change and that they can be acquired or shut down. ## The honest case for self-hosted leave management The maths shifts when you run the numbers over a full year, or three. | Scenario | SaaS ($1.50/user/mo) | Self-hosted (fortrabbit) | Self-hosted (VPS) | | ------------------ | -------------------- | ------------------------ | ----------------- | | 15 people, 1 year | $270 | €60 | ~$60 or $0 | | 30 people, 1 year | $540 | €60 | ~$60 or $0 | | 30 people, 3 years | $1,620 | €180 | ~$180 or $0 | The self-hosted cost is per server, not per user. A [€4.5/month fortrabbit plan](https://www.fortrabbit.com/pricing?php=xs&database=xs&storage=xs&traffic=xs&backups=xs&jobs=xs&key-value-store=off) or a $6/month VPS runs Who's OOO for a team of 5 or a team of 200. And if you already have a server, adding Who's OOO costs nothing. The setup cost is real. It's a developer afternoon, not ten minutes. But it's a one-time investment, not a recurring one. Once it's running, it runs. You're not paying a subscription forever in exchange for someone else handling the ops. On data: when you self-host, employee absence records, including sick leave, parental leave, anything sensitive, never leave your infrastructure. There's no vendor to trust, no DPA to sign, no audit to commission. The data is in your datacenter under your control. On vendor risk: SaaS tools can change pricing mid-year, add paywalls to features you relied on, or get acquired by a company whose values or roadmap don't match yours. A few HR tools have pivoted or shut down in the last five years. When you own the software, that risk disappears. ## When to choose leave management without SaaS **Choose SaaS if:** - Nobody on your team is comfortable with Docker or server maintenance - You need a mobile app now - You need SSO/LDAP on day one - You need to be running in under an hour and can't commit an afternoon **Choose self-hosting if:** - There's a developer on the team who can spend an afternoon on setup (or use [fortrabbit](https://www.fortrabbit.com/pricing?php=xs&database=xs&storage=xs&traffic=xs&backups=xs&jobs=xs&key-value-store=off) for a `git push` deployment) - You care about where employee data lives (EU, GDPR, or just principle) - You're cost-conscious over time and prefer one-time work over recurring cost - You want an open source absence tracker you can inspect, fork, and modify - You want to own the software and not depend on a vendor's roadmap ## What self-hosted leave management looks like in practice [Who's OOO](https://github.com/igornast/who-is-out-of-office) is the open source leave management tool I built when I wanted the self-hosted path. Symfony 7.4 on PHP 8.5, AGPL-3.0 license. The core flow: employee submits a request, manager gets a Slack notification with approve/decline buttons, calendar updates, Slack statuses sync automatically on approved days. It's not a feature-equivalent replacement for every SaaS tool. It doesn't have a mobile app or multi-level approvals or SSO. If those are blockers for you, it won't be the right fit. What it does cover: leave requests, manager approvals, leave types with configurable balances, automatic public holiday imports for 100+ countries, iCal export, Slack integration with status sync, and a team calendar. For most teams of under 50 people, that's the full list of things that actually matter. For teams that want a lightweight self-hosted HR tool without the overhead of a full HRIS, it covers the practical requirements. ### Deployment: what setup actually looks like The easiest way to get Who's OOO running is on [fortrabbit](https://www.fortrabbit.com/pricing?php=xs&database=xs&storage=xs&traffic=xs&backups=xs&jobs=xs&key-value-store=off), a PHP hosting platform built for apps like this. We run our own instance there. The total cost is €4.5/month for the full stack: PHP, MySQL, storage, backups, and a job runner. You deploy with `git push` and fortrabbit handles the rest. If you prefer to manage your own infrastructure, Who's OOO also ships as a Docker Compose stack. You pull the repo, configure a `.env` file with your Slack credentials and database settings, and run `docker compose up`. The full setup, including first login, takes one developer afternoon. Ready to set it up? The [installation guide](/docs/installation) covers every step, and most teams are running in an afternoon. If you want to inspect the code first, the [full source is on GitHub](https://github.com/igornast/who-is-out-of-office) under the AGPL-3.0 license. ## Related guides - [Tracking team leave in a spreadsheet: when to stop](/blog/leave-tracker-spreadsheet-alternative) - [Who's OOO vs TimeOff.Management: two open-source leave planners compared](/blog/whos-ooo-vs-timeoff-management) --- Category # \#comparison 3 posts in `comparison` on the Who's OOO blog. [← All posts](/blog) July 11, 2026 ## [ TimeOff.Management vs Who's OOO: Open Source Leave Management Compared ](https://whoisooo.app/blog/whos-ooo-vs-timeoff-management) Comparing TimeOff.Management vs Who's OOO? Full open source leave management comparison: stack, Slack sync, LDAP, API, and which self-hosted tool fits your team. [\#comparison](/blog/tag/comparison/) [ Read more — TimeOff.Management vs Who's OOO: Open Source Leave Management Compared ](https://whoisooo.app/blog/whos-ooo-vs-timeoff-management) April 11, 2026 ## [ Google Sheets Leave Tracker: A Free Self-Hosted Alternative ](https://whoisooo.app/blog/leave-tracker-spreadsheet-alternative) Outgrown your Google Sheets leave tracker? Here's a free self-hosted alternative with approvals, Slack sync, and shared team calendars. [\#comparison](/blog/tag/comparison/) [ Read more — Google Sheets Leave Tracker: A Free Self-Hosted Alternative ](https://whoisooo.app/blog/leave-tracker-spreadsheet-alternative) April 4, 2026 ## [ Self-Hosted vs SaaS Leave Management: Open Source Tools Compared ](https://whoisooo.app/blog/self-hosted-vs-saas-leave-management) Comparing self-hosted leave management to SaaS? See the real cost difference, GDPR implications, and which open source absence tracker fits your team. [\#comparison](/blog/tag/comparison/) [ Read more — Self-Hosted vs SaaS Leave Management: Open Source Tools Compared ](https://whoisooo.app/blog/self-hosted-vs-saas-leave-management) --- Category # \#news 2 posts in `news` on the Who's OOO blog. [← All posts](/blog) May 9, 2026 ## [ HR SaaS Vendor Risk: When Your Leave Management Tool Disappears ](https://whoisooo.app/blog/hr-saas-vendor-risk) Real stories of HR SaaS tools that shut down, got acquired, or hiked prices, and what they tell you about the cost of depending on a vendor for leave management. [\#news](/blog/tag/news/) [ Read more — HR SaaS Vendor Risk: When Your Leave Management Tool Disappears ](https://whoisooo.app/blog/hr-saas-vendor-risk) March 31, 2026 ## [ Why I Built Who's OOO ](https://whoisooo.app/blog/why-i-built-whosooo) Every company I worked at tracked leave differently. Spreadsheets, calendar invites, Slack messages that got buried. At some point I got tired of it and built something. [\#news](/blog/tag/news/) [ Read more — Why I Built Who's OOO ](https://whoisooo.app/blog/why-i-built-whosooo) --- Category # \#tech 2 posts in `tech` on the Who's OOO blog. [← All posts](/blog) June 7, 2026 ## [ Who's OOO 0.3.0: Feed Customization, Org Chart, and Public Holidays ](https://whoisooo.app/blog/whos-ooo-0-3-0) A short recap of what shipped in Who's OOO 0.3.0: a customizable iCal feed, a better org chart, public holidays in the request calendar, and an in-app news feed. [\#tech](/blog/tag/tech/) [ Read more — Who's OOO 0.3.0: Feed Customization, Org Chart, and Public Holidays ](https://whoisooo.app/blog/whos-ooo-0-3-0) April 23, 2026 ## [ Adding Two-Factor Authentication to Who's OOO ](https://whoisooo.app/blog/adding-2fa-to-whosooo) How I added optional 2FA to Who's OOO: encrypted TOTP secrets, Argon2id backup codes, rate limiting, and no open-redirect traps. [\#tech](/blog/tag/tech/) [ Read more — Adding Two-Factor Authentication to Who's OOO ](https://whoisooo.app/blog/adding-2fa-to-whosooo) --- # Who's OOO 0.3.0: Feed Customization, Org Chart, and Public Holidays > A short recap of what shipped in Who's OOO 0.3.0: a customizable iCal feed, a better org chart, public holidays in the request calendar, and an in-app news feed. Who's OOO 0.3.0 is out today. It's a feature release rather than a big architectural change, so this is a short recap of what's in it and the thinking behind a couple of the choices. Most of the work this cycle went into the calendar feed and the org chart, with public holidays and an in-app news feed rounding it out. Here's what changed. ## Customizable iCal feed Who's OOO has exported an iCal feed for a while, so you can subscribe to who's away from inside Google Calendar, Apple Calendar, or whatever you already use. The problem was that the feed was all-or-nothing. You got everything, every absence for everyone, whether or not it was relevant to you. 0.3.0 adds a customization step. Before you copy the subscription URL, a modal lets you pick what the feed contains. The selection is encoded into the feed itself, so two people on the same instance can subscribe to two different views without any server-side per-user state to manage. ![The calendar subscription customization modal in Who's OOO, with Auto and Custom toggles for team members and holiday calendars, a team tree showing who reports to whom, and France, Germany, and Netherlands selected as holiday calendars](/assets/images/blog/release-0-3-0/feed_customization_modal.png) The iCal integration settings got the same treatment. It's the kind of feature that's easy to ship as "here's a URL, good luck," and I wanted to avoid that. ## Org chart 0.3.0 adds an org chart. It's the view that shows who reports to whom, and it's wired to the same structure that drives a lot of the approval logic underneath. Being able to see and edit those relationships in one place makes the rest of the app easier to reason about, since that structure decides who approves a given leave request. ![The Who's OOO organization chart, with an admin at the top connected to four direct reports, and Expand all and First level only controls for changing how much of the hierarchy is shown](/assets/images/blog/release-0-3-0/org_chart.png) ## Public holidays in the leave request calendar Public holidays were already available through the Date Nager API integration, but they weren't visible on the new leave request calendar picker. Now they appear directly there, so when someone is picking dates they can see which days are public holidays for their country before they request time off. Less guessing, fewer requests that overlap a day the office is closed anyway. ## An in-app news feed There's now a news feed area inside the app, with a link back to the homepage. It reads from the same JSON feed that powers this blog, so a post like this one can surface inside an instance without anyone having to redeploy or check the site manually. It's a small thing, but it gets the project's updates in front of the people actually running it. ## Fixes Two fixes I want to mention. The invitation confirmation flow had a rough edge that I tightened up, along with the tests around it, so onboarding a new person to an instance is more reliable. And the checkboxes in the calendar customization modal had a styling issue that's now sorted. ## In closing Nothing in 0.3.0 changes how you'd describe Who's OOO. It's still a self-hosted leave planner. But the feed is more useful when you can shape it, the calendar tells you more before you commit to a date, and updates now reach you inside the app instead of waiting for you to come looking. Who's OOO is open source and free to self-host. The full changelog and the code are on [GitHub](https://github.com/igornast/who-is-out-of-office). If the project is useful to you, give it a star, or consider sponsoring the development if you want to support it. --- # TimeOff.Management vs Who's OOO: Open Source Leave Management Compared > Comparing TimeOff.Management vs Who's OOO? Full open source leave management comparison: stack, Slack sync, LDAP, API, and which self-hosted tool fits your team. If you run a small team and want an open source leave tracker, two credible options come up: [TimeOff.Management](https://github.com/timeoff-management/timeoff-management-application) and [Who's OOO](https://github.com/igornast/who-is-out-of-office). This open source leave management comparison covers stack, Slack integration, LDAP, accruals, and deployment, so you can pick the right self-hosted leave tracker for your team. Who's OOO is AGPL-3.0 licensed, TimeOff.Management is MIT. Both are free to self-host. Both solve the core team absence management problem. I built Who's OOO, so I have a bias, but I'll tell you where TimeOff.Management is the better choice. ## Feature comparison: Who's OOO vs TimeOff.Management | Feature | Who's OOO | TimeOff.Management | |---|---|---| | License | AGPL-3.0 | MIT | | Self-hostable | Yes (Docker Compose) | Yes | | Managed hosting option | [€4.5/month on fortrabbit](https://www.fortrabbit.com/pricing?php=xs&database=xs&storage=xs&traffic=xs&backups=xs&jobs=xs&key-value-store=off) | [$1/user/month](https://timeoff.management/pricing) | | Stack | PHP 8.5 / Symfony 7.4 | Node.js / Sequelize (MySQL or SQLite) | | Slack integration | Yes (notifications + status sync) | No (email notifications only) | | Slack status auto-sync | Yes | No | | iCal export | Yes | Yes | | Public holidays | Auto-import, 100+ countries | Manually managed | | LDAP authentication | No | Yes | | REST API | No | Yes | | Leave accruals / carry-over | No | Yes | | Dark mode | Yes (+ 4 palettes) | No | | Mobile app | No | No | | Latest release | Active development | [v1.4.5, Jan 2024](https://github.com/timeoff-management/timeoff-management-application/releases) | If you want to try Who's OOO, deploy it on [fortrabbit for €4.5/month](https://www.fortrabbit.com/pricing?php=xs&database=xs&storage=xs&traffic=xs&backups=xs&jobs=xs&key-value-store=off) or self-host with Docker Compose. See the [installation guide](/docs) or [star it on GitHub](https://github.com/igornast/who-is-out-of-office). ## Where Who's OOO is the better choice Slack status sync is the feature nobody else has. On approved leave days, Who's OOO sets the employee's Slack status to "On leave" automatically, and clears it at midnight on the last day. Teammates don't need to check a team absence calendar. The information appears passively in the tool they already have open. TimeOff.Management has no Slack integration at all; its notifications are email-only, so nothing shows up in the chat tool your team already lives in. The stack is modern PHP. If your team runs PHP or Symfony, Who's OOO fits your deployment stack. You can read the code, open a PR, and understand what's running: Symfony 7.4 on PHP 8.5, PHPStan Level 8, 95%+ test coverage with Pest. Deployment is Docker-first. Who's OOO ships with a Docker Compose file and is designed to run in containers from day one. TimeOff.Management ships a Dockerfile too, but there's no Compose file and the README doesn't mention Docker, so the documented path is still the manual one: clone the repo, install Node and SQLite, run the migrations yourself. Leave visibility is controlled per type. Each leave type has a toggle for whether it appears on the team calendar, so sick leave can be approved without being visible to the full team. Database choice stays portable. Who's OOO is developed primarily with MySQL, but the codebase keeps the infrastructure layer cleanly separated from the domain logic, so swapping in PostgreSQL or SQLite is a configuration change, not a rewrite. To be fair, TimeOff.Management is flexible here too: it uses the Sequelize ORM and its production config actually defaults to MySQL, though only the SQLite driver ships by default, so pointing it at another database means installing the driver yourself. Call this one roughly even. ## Where TimeOff.Management is the better choice LDAP authentication is the big one. TimeOff.Management supports LDAP login. Who's OOO has no SSO, LDAP, or SAML support right now. If your team uses Active Directory or any centralized identity provider, TimeOff.Management is the clear choice. It also has a REST API with an auto-generated token. Who's OOO has no API at all. If you need to integrate leave data into another system, like HR workflows or custom reporting scripts, TimeOff.Management wins. Leave accruals and carry-over are built in. TimeOff.Management has per-department accrued allowances and a carry-over calculation. Who's OOO tracks balances but has no accrual or carry-over engine. If your leave policy involves rollover or monthly accruals, TimeOff.Management handles it natively. The project is older. TimeOff.Management has been around since 2015 and has 1,000+ GitHub stars, so more troubleshooting threads exist online. One caveat worth knowing before you commit: its [last release was v1.4.5 in January 2024](https://github.com/timeoff-management/timeoff-management-application/releases), with no commits since, so in 2026 it reads as largely dormant. Managed hosting is available at [$1/user/month](https://timeoff.management/pricing). Who's OOO can be deployed on [fortrabbit](https://www.fortrabbit.com/pricing?php=xs&database=xs&storage=xs&traffic=xs&backups=xs&jobs=xs&key-value-store=off) for a flat €4.5/month regardless of team size, or self-hosted with Docker Compose. Whether per-user or flat-rate is cheaper depends on your team size: past roughly five people, the flat €4.5 wins. And if your team is JavaScript-first, TimeOff.Management's Node.js stack fits your deployment workflow and is easier to contribute to. ## Self-hosting in practice: what setup actually looks like For Who's OOO, the simplest option is [fortrabbit](https://www.fortrabbit.com/pricing?php=xs&database=xs&storage=xs&traffic=xs&backups=xs&jobs=xs&key-value-store=off): deploy with `git push`, €4.5/month for the full stack including PHP, MySQL, storage, and backups. We run our own instance there. Alternatively, clone the repo, copy `.env.example`, run `docker compose up`. The full PHP 8.5 + Symfony stack runs containerised, so no host PHP or Composer is required. Setup takes under 10 minutes either way. TimeOff.Management is a Node.js application, so it needs Node on the host or a manually configured Docker setup. Its own hosted service covers the no-ops route at $1/user/month, and its longer history means more troubleshooting threads to search, though the codebase itself has been quiet since early 2024. If fast, low-effort deployment is a priority, Who's OOO on fortrabbit or Docker gets you there with less setup. ## Which is the best open source leave management tool? Neither is universally better. The right answer depends on your stack and which features you need today. If you need LDAP or accruals, TimeOff.Management is the best self-hosted leave tracker for those requirements. If Slack status sync, Docker-first deployment, and a modern PHP/Symfony codebase matter more, Who's OOO is the stronger fit as an employee leave tracker. For teams evaluating PTO management software that stays open source, run both in a test environment before committing. ## Who should use which Use Who's OOO if: - Your team runs Symfony or PHP and you want a codebase you can read and contribute to - Slack status sync matters, so absences should appear passively in Slack without anyone checking - You want Docker-first deployment on MySQL (with PostgreSQL or SQLite reachable via config when you need it) - You don't need LDAP or leave accruals today Use TimeOff.Management if: - You need LDAP or SSO authentication now - You need leave accruals or carry-over rules - You need a REST API to integrate leave data with other systems - You want optional managed hosting at $1/user/month - Your team is Node.js-first ## The honest summary If you need LDAP or leave accruals today, TimeOff.Management is the better choice. Who's OOO doesn't have those yet. If you want Slack status sync, a Docker-first self-hosted HR tool, and a modern Symfony codebase, Who's OOO covers a gap TimeOff.Management doesn't address. Try Who's OOO: [GitHub repo](https://github.com/igornast/who-is-out-of-office) · [Installation guide](/docs) · [Deploy on fortrabbit](https://www.fortrabbit.com/pricing?php=xs&database=xs&storage=xs&traffic=xs&backups=xs&jobs=xs&key-value-store=off) ## Related guides - [Self-hosted vs SaaS leave management: how to decide](/blog/self-hosted-vs-saas-leave-management) --- # Why I Built Who's OOO > Every company I worked at tracked leave differently. Spreadsheets, calendar invites, Slack messages that got buried. At some point I got tired of it and built something. Every company I worked at tracked leave differently. Spreadsheets, shared calendar invites buried under meeting noise, Slack messages that nobody checked. Some places had HR tools that were technically capable but so heavy that people avoided them. At some point I got tired of the problem being unsolved at the developer level and decided to build something I'd actually want to use. ## The problem with existing tools Most leave management software falls into one of two categories: too simple (a shared spreadsheet with some color coding) or too complex (an enterprise HR system that requires a three-day onboarding to configure). Neither fits a small engineering team that just wants to know who's off next week without pulling out a manual. The simpler tools lack approval flows, Slack integration, or multi-country holiday support. The enterprise tools have all of that but come with a subscription fee and more configuration surface area than you want to maintain. I wanted something in the middle: self-hosted, open source, Symfony-based, and opinionated enough to work out of the box. ## The stack I've been building Symfony applications since 2015, so that part was an easy call. Symfony 7.4 on PHP 8.5 gives a solid foundation, and I'd rather extend something I understand deeply than learn a new framework for a side project. The more interesting choice was using EasyAdmin for the entire UI, not just an admin panel, but the whole application. EasyAdmin handles CRUD operations, access control, and list views with very little code. For a leave management tool, that's almost all you need. There are rough edges when you push it beyond typical admin panel territory, but the productivity gains are worth the occasional workaround. For testing, the project uses Pest with PHPStan at Level 8 and 75%+ code coverage. That level of coverage matters less for happy paths and more for catching edge cases around date calculations: overlapping leave requests, weekend exclusions, public holiday conflicts. Those are the things that break in production. ## What it does The core workflow is simple. An employee submits a leave request. A manager receives a Slack notification with approve and decline buttons. The team calendar updates. A weekly digest goes out on Monday morning so everyone knows who's out that week. Beyond that: iCal export so you can subscribe in Google Calendar or Outlook, automatic public holiday imports via the Nager.Date API (supporting 100+ countries), four color palettes with light and dark mode, and role-based access with Admin, Manager, and User roles. It's opinionated, and that's intentional. The goal was never to build a platform. It was to solve a specific, common problem with the minimum viable configuration. ## Open source and self-hosted I built this on my own, with the support of fortrabbit's CEO, and we now use it daily for the team. Making it open source felt right. The kind of tool that helps teams shouldn't be locked behind a SaaS subscription. AGPL-3.0 license, ready to run in minutes. We host it on [fortrabbit](https://www.fortrabbit.com/pricing?php=xs&database=xs&storage=xs&traffic=xs&backups=xs&jobs=xs&key-value-store=off), a PHP hosting platform I work at, for €4.5/month, but it runs anywhere with Docker Compose. ## In closing Who's OOO is [on GitHub](https://github.com/igornast/who-is-out-of-office). If you're running a small team and tired of juggling spreadsheets and Slack threads, give it a try. If it's useful to you, a star on GitHub or a [sponsorship](https://github.com/sponsors/igornast) helps keep the project going. --- Documentation # Everything you need to get started Step-by-step guides for installing, configuring, and running Who's OOO on your own infrastructure. ## Getting Started - [Introduction](https://whoisooo.app/docs/introduction) - [Installation](https://whoisooo.app/docs/installation) - [Production Deployment](https://whoisooo.app/docs/production-deployment) [ Get started → ](https://whoisooo.app/docs/introduction) ## Configuration - [Configuration](https://whoisooo.app/docs/configuration) - [Leave Types](https://whoisooo.app/docs/leave-types) - [Public Holidays](https://whoisooo.app/docs/public-holidays) [ Get started → ](https://whoisooo.app/docs/configuration) ## Administration - [Roles & Permissions](https://whoisooo.app/docs/roles-permissions) - [User Invitations](https://whoisooo.app/docs/user-invitations) [ Get started → ](https://whoisooo.app/docs/roles-permissions) ## Integrations - [Slack Integration](https://whoisooo.app/docs/slack-integration) - [iCal Feeds](https://whoisooo.app/docs/ical-feeds) [ Get started → ](https://whoisooo.app/docs/slack-integration) --- # Configuration > Environment variables and the admin-editable App Settings that control how Who's OOO behaves Who's OOO is configured in two places: environment variables for secrets and infrastructure, and App Settings, which admins change from the UI without a deploy. ## Environment variables There are two files, and different programs read them: - `app/.env.local` is read by Symfony. All variables on this page go here (or into your host's environment settings, e.g. the fortrabbit Dashboard). - `.env` at the repository root is read by Docker Compose only. It holds the database container's credentials and the `COMPOSE_FILE` pin for the production stack. See [Production Deployment](/docs/production-deployment#1-configure). The development Docker stack provides working defaults, so locally you only need to add variables for optional integrations. ### Core (production) | Variable | Description | |---|---| | `APP_ENV` | `prod` in production. | | `APP_SECRET` | Random secret. Generate with `openssl rand -hex 16`. | | `APP_BASE_URL` | Public URL of your instance, without a trailing slash. Used for links in emails sent by workers and scheduled jobs. | | `TRUSTED_PROXIES` | IP or CIDR of your TLS-terminating reverse proxy. See [Put a TLS proxy in front](/docs/production-deployment#2-put-a-tls-proxy-in-front). | | `TRUSTED_HOSTS` | Regular expression matching your public host name, e.g. `'^leave\.example\.com$'`. Empty accepts any host. | | `DATABASE_URL` | MySQL connection string. | | `MAILER_DSN` | SMTP connection, e.g. `smtp://user:pass@smtp.example.com:587`. | | `EMAIL_FROM_ADDRESS`, `EMAIL_FROM_NAME` | Sender address and name for all outgoing email. | | `TOTP_ENCRYPTION_KEY` | Encrypts two-factor authentication secrets. Generate with `openssl rand -base64 32`. | | `MESSENGER_TRANSPORT_DSN` | Queue for the background workers, e.g. `doctrine://default?auto_setup=0`. | | `APP_SETTINGS_FILE` | Optional. Path of the App Settings YAML file (see below). | ### Slack integration These variables enable the [Slack integration](/docs/slack-integration) for leave request approvals, weekly digests, and personal notifications. | Variable | Description | |---|---| | `SLACK_DSN` | Slack bot token in DSN format: `slack://TOKEN@default?channel=CHANNEL`. Replace `TOKEN` with your `xoxb-` bot token. | | `SLACK_SIGNING_SECRET` | Found under **Basic Information → App Credentials → Signing Secret** in your Slack App settings. Used to verify incoming requests from Slack. While it is empty, every request from Slack is rejected. | | `SLACK_AR_APPROVE_CHANNEL_ID` | Channel ID for leave request approval messages (with Approve / Reject buttons). Right-click the channel in Slack → **View channel details** → copy the Channel ID. | | `SLACK_AR_HR_DIGEST_CHANNEL_ID` | Channel ID for the weekly digest (who's out, birthdays, anniversaries). Same steps as above. | > **Tip:** Channel IDs look like `C04ABCD1234`. Don't use the channel name — it won't work. ### Slack status sync Only needed for [Slack status sync](/docs/slack-integration#slack-status-sync). | Variable | Description | |---|---| | `SLACK_CLIENT_ID` | Client ID of your Slack app (**Basic Information → App Credentials**). | | `SLACK_CLIENT_SECRET` | Client Secret of your Slack app. | | `SLACK_TOKEN_ENCRYPTION_KEY` | Encrypts the admin's OAuth token at rest. Generate with `php -r "echo base64_encode(sodium_crypto_secretbox_keygen());"`. Changing it means authorizing status sync again. | ### iCal feeds This variable enables the [iCal feed](/docs/ical-feeds) feature, allowing users to subscribe to their approved leave requests from any calendar app. | Variable | Description | |---|---| | `ICAL_SECRET` | A random string of at least 32 characters, used to generate secure, per-user feed URLs. Generate one with `openssl rand -hex 16`. | > **Warning:** Changing `ICAL_SECRET` invalidates all existing feed URLs. Every user will need to re-copy their URL and update their calendar subscriptions. ## Application settings Admins change how the app behaves from **Settings → App Settings** in the sidebar. Edit the values and click **Save Changes**. No code change or deploy is needed. | Setting | Default | What it does | |---|---|---| | Auto Approve | Off | Approve leave requests automatically | | Auto Approve Delay (minutes) | 115 | How long to wait before auto-approving | | Default Annual Allowance (days) | 25 | Annual leave days pre-filled for new users | | Minimum Notice (days) | 1 | Minimum notice for a leave request (0 means no minimum) | | Max Consecutive Days | 0 | Maximum consecutive days per request (0 means unlimited) | | Skip weekend public holidays | Off | Leave public holidays that fall on a weekend out of notifications | | Enable Slack status auto-sync | Off | Turn [Slack status sync](/docs/slack-integration#slack-status-sync) on or off | | Send day, Send time, Timezone | Monday, 08:15, UTC | When the [Slack weekly digest](/docs/slack-integration#weekly-digest) is posted | | Organization Name | Your Organization | Your organization's display name | Settings are stored in a YAML file. By default it lives at `app/src/Module/Settings/Config/app_setting.yaml`; set `APP_SETTINGS_FILE` to store it somewhere else. The Docker production stack keeps it on a shared volume so the web app and the workers see the same values (see [Application settings storage](/docs/production-deployment#application-settings-storage)). --- # iCal Feeds > Subscribe to a personal calendar feed of approved leave requests ## Overview Each user gets a personal iCal feed URL that contains their approved leave requests. Subscribe to it from Google Calendar, Apple Calendar, or Outlook to see absences alongside your regular schedule. The feed updates automatically — when a new leave request is approved, it appears in your calendar without any action. ## Admin setup Before users can generate feed URLs, an admin must set the `ICAL_SECRET` environment variable. This secret is used to generate secure, per-user URLs and must be at least 32 characters long. Add to `app/.env.local`: ```dotenv ICAL_SECRET=a-long-random-string ``` Generate a random value with: ```bash openssl rand -hex 16 ``` > **Warning:** Changing `ICAL_SECRET` invalidates all existing feed URLs. Every user will need to re-copy their URL and update their calendar subscriptions. ## Get your subscription URL 1. Open **Profile Settings** in the **Account** section of the sidebar. 2. On the **Connected Services** card, find **Calendar Subscription** with your personal iCal feed URL. 3. Click **Copy URL** to copy it to your clipboard. You can also click **Subscribe** on the **Calendar** page to copy the same URL. To choose what appears in your feed, click **Customize what's in this feed**. The URL looks like: ``` https://your-domain.com/api/calendar/{userId}/{secret}.ics ``` ![Profile settings showing the Connected Services section with calendar subscription URL](/assets/images/docs/user-profile-ical.png) *Your personal iCal feed URL on the Connected Services card* ## Subscribe in Google Calendar 1. Open [Google Calendar](https://calendar.google.com). 2. On the left sidebar, click the **+** next to "Other calendars". 3. Select **From URL**. 4. Paste the iCal feed URL. 5. Click **Add calendar**. Google Calendar syncs external feeds roughly every 12–24 hours. New leave requests may take a while to appear. > **Tip:** Google Calendar's sync interval is not configurable. If you need faster updates, use Apple Calendar or Outlook, which tend to refresh more frequently. ## Subscribe in Apple Calendar 1. Open **Calendar** on macOS. 2. Go to **File → New Calendar Subscription…** 3. Paste the iCal feed URL and click **Subscribe**. 4. Set the auto-refresh interval (e.g. every hour) and click **OK**. On iOS: go to **Settings → Calendar → Accounts → Add Account → Other → Add Subscribed Calendar** and paste the URL. ## Subscribe in Outlook 1. Open [Outlook on the web](https://outlook.office.com/calendar). 2. Click **Add calendar** in the sidebar. 3. Select **Subscribe from web**. 4. Paste the iCal feed URL, give it a name, and click **Import**. ## Regenerate your URL If you suspect your feed URL has been shared or compromised, you can regenerate it: 1. Open **Profile Settings → Connected Services**. 2. Under **Calendar Subscription**, click **Regenerate** and confirm with **Regenerate URL**. 3. Copy the new URL and update your calendar subscriptions. The old URL stops working immediately. > **Warning:** After regenerating, you must update the URL in every calendar app where you subscribed. The old URL will return an error. --- # Installation > Try Who's OOO locally with Docker, then deploy it to fortrabbit or your own server ## Deploy on fortrabbit (recommended) The easiest way to run Who's OOO in production is on [fortrabbit](https://www.fortrabbit.com) — a PHP hosting platform purpose-built for Symfony apps. No Docker required. 1. Create a fortrabbit App — use the [minimal stack configuration](https://www.fortrabbit.com/pricing?php=xs&database=xs&storage=xs&traffic=xs&backups=xs&jobs=xs&key-value-store=off) (€4.5/month) when selecting components in the app creation wizard 2. Install the [fortrabbit GitHub app](https://github.com/apps/fortrabbit) on your GitHub account 3. Connect your GitHub repository to the fortrabbit App — you can select an existing repo or create a new one during app setup 4. Map your deployment branch (e.g. `main`) to the fortrabbit environment 5. Configure your `.env` variables in the fortrabbit Dashboard 6. Deploy by pushing to your mapped branch — fortrabbit deploys automatically on each `git push`, or you can trigger deployments manually through the Dashboard fortrabbit's [GitHub integration](https://www.fortrabbit.com/features/github-integration) handles deployment pipelines with Composer and Node.js, intelligent caching for fast successive deployments, and comprehensive settings for deployment strategy, include/exclude patterns, and source directory configuration. We run our own Who's OOO instance on fortrabbit. Whichever host you choose, a few steps from the [Production Deployment](/docs/production-deployment) guide apply everywhere: - Create the first admin account. Production never loads demo data, so nobody can log in until you [create an admin by hand](/docs/production-deployment#4-create-the-first-admin-account). - Run the background workers. Emails, auto-approvals, and scheduled jobs need `messenger:consume async` and `messenger:consume scheduler_default scheduler_weekly_digest` running. See [Background workers](/docs/production-deployment#background-workers). - **Compile assets after each deploy** when you're not using the Docker production image: `php bin/console asset-map:compile`. ## Try it locally with Docker The quickest way to look around is the development stack. It boots with demo data, so you can log in straight away. > **Warning:** The development stack is built to be thrown away. Its entrypoint drops the database and reloads demo data every time the container starts. Never use it for real data. For a self-hosted production install, follow [Production Deployment](/docs/production-deployment). ### Prerequisites | Tool | Version | |----------------|---------| | Docker | 20+ | | Docker Compose | 2+ | | Git | any | | [just](https://github.com/casey/just) | any | You don't need PHP, MySQL, Node, or Composer on your machine. Everything runs inside containers, and `just start` is a shortcut for `docker compose up -d`. ### Clone and start ```bash git clone https://github.com/igornast/who-is-out-of-office.git cd who-is-out-of-office just start ``` These services start: | Service | Port | Description | |------------------|------------|----------------------------------------------| | nginx | 80 | Web server | | php | — | PHP-FPM with Xdebug | | worker-async | — | Sends emails and handles async messages | | worker-scheduler | — | Runs scheduled jobs (auto-approve, digest, …) | | db | 3306 | MySQL 8.4 | | mailer | 8025 | Mailpit, a local email catcher | On first boot the PHP container installs Composer dependencies, waits for the database, runs migrations, and loads demo data. When the container logs show "Starting PHP-FPM", the app is ready. ### Log in Open [http://localhost/login](http://localhost/login) in your browser. After logging in you land on the dashboard at `/app/dashboard`. ![Login page with email and password fields](/assets/images/docs/login.png) *The login page* The demo data includes an account for every role: | Role | Email | Password | |---------|----------------------|----------| | Admin | admin@whoisooo.app | 123 | | Manager | manager@whoisooo.app | 123 | | User | user@whoisooo.app | 123 | > **Warning:** These accounts exist only in the development stack. Production never loads demo data, so you [create the first admin yourself](/docs/production-deployment#4-create-the-first-admin-account). ### Dashboard overview What you see on the dashboard depends on your role: - **Admin** — full organization view: leave balance stats, all pending requests, who's out today, upcoming absences, birthdays, and work anniversaries. - **Manager** — same sections, scoped to your direct reports. - **User** — your personal leave balance, who's out today and this week, your upcoming absences, and organization-wide birthdays and work anniversaries. The sidebar groups pages into sections: **My Leave** (your requests and the calendar), **My Team** (for managers and admins), **Organization**, **Settings** (admins only), and **Account** (profile, appearance, security). ![Admin dashboard showing stats cards, upcoming absences, birthdays, and work anniversaries](/assets/images/docs/dashboard.png) *The admin dashboard with all sections visible* ### Local email with Mailpit The development stack includes [Mailpit](https://mailpit.axllent.org/), a local email catcher. Every email the app sends (invitations, leave request notifications, password resets) is captured here instead of being delivered. Open [http://localhost:8025](http://localhost:8025) to view caught emails. > **Tip:** No SMTP configuration needed for local development — Mailpit is pre-configured in the Docker setup. --- # Introduction > What Who's OOO is, how it works, and what it can do for your team ## What is Who's OOO? Who's OOO is an open-source leave management application built for small to mid-sized teams. It provides a central place for employees to request time off and for managers to review and approve requests — all with minimal configuration and no vendor lock-in. Built with Symfony and Docker, the application runs entirely on your own infrastructure, keeps all data under your control, and ships under the AGPL-3.0 licence. ## Core concepts **Leave requests** are submitted by team members and follow an Approve / Reject workflow. Each request includes a date range, leave type, and an optional note. Managers can act on requests from the web interface or directly from Slack using approval buttons. **Leave types** are configurable — vacation, sick days, parental leave, and anything else your organisation uses. Each type can carry its own rules around approval, balance tracking, and visibility. **Public holidays** are imported per country and year. They are subtracted from workday calculations automatically — if a public holiday falls within a leave request, that day is not counted against the employee's balance. **The team calendar** gives everyone a read-only view of who is away and when, reducing the need to ask around before scheduling meetings. ![Team calendar view](/assets/images/docs/calendar.png) *The team calendar — see at a glance who is away and when* ## How the data flows 1. An employee submits a leave request through the web interface. 2. Managers are notified — the request is posted to the Slack approval channel (with Approve / Reject buttons) and the user's manager receives an email notification. 3. The approver reviews and accepts or declines the request in one click, from Slack or the web app. Admins can also turn on auto-approval. 4. The calendar and balance counters update automatically. 5. The employee hears back by email, or by a personal Slack DM when the decision was made in Slack. 6. Approved leave appears in the employee's iCal feed, syncing to Google Calendar, Apple Calendar, or Outlook. 7. While the employee is away, their Slack status can show it automatically (e.g. 🌴 "Vacation until Apr 18"). ## What it is not Who's OOO is focused on leave management. It is not a general HR platform, a payroll tool, or a time-tracking system. If you need those, you will want to integrate a dedicated tool alongside it. --- # Leave Types > Configure the categories of leave available to your team and how leave balances work ## Leave types on a fresh install A fresh production install has **no leave types**. Create the ones your organisation uses before anyone submits a request. The development stack's demo data includes a typical set you can use as a starting point: - **Vacation** 🌴: standard paid vacation, tracked against the annual balance - **Sick Leave** 🤒: unplanned absence due to illness - **Conference** 📚: time away for conferences and professional events - **Parental Leave** 👶: maternity, paternity, or adoption leave ## Creating a leave type Leave types are managed by admins under **Settings → Leave Types** in the sidebar. Use the add button at the top of the list to create one. Each type has the following fields: - **Name**: what employees see when submitting a request (e.g. "Study leave") - **Icon**: a visual identifier shown alongside the leave type - **Slack status emoji**: the emoji used for [Slack status sync](/docs/slack-integration#slack-status-sync), e.g. `:palm_tree:`. Defaults to `:calendar:` when blank. - **Sort**: display order; lower numbers appear first - **Affects balance**: whether the type counts against the employee's annual allowance - **Background colour**, **Border colour**, **Text colour**: how the type looks in the team calendar Leave types can be edited at any time but not deleted, so past leave requests always keep their type. ## Allowances and balances Each employee has an **annual leave allowance** and a **current leave balance**, set by an admin in the user's **Employee of record** tab under **My Organization**. New users are pre-filled with the **Default Annual Allowance** from [App Settings](/docs/configuration#application-settings). Only leave types with **Affects balance** turned on are deducted from the balance. Each employee also has an **absence balance reset day** (usually 1 January). On that day the employee's annual allowance is added to their current balance, so unused days carry over, and the reset day moves forward one year. The job runs daily at midnight, so the `worker-scheduler` service must be running. --- # Production Deployment > Run Who's OOO in production with docker-compose.prod.yml: configuration, TLS proxy, first admin, workers, backups, and upgrades For production, Who's OOO ships a separate Compose file, `docker-compose.prod.yml`. It builds the `prod` image target, mounts no source code, sets `APP_ENV=prod`, and starts two Messenger workers next to PHP-FPM, nginx, and MySQL. The PHP container compiles the asset map on every start, so there is no separate asset build step. The stack serves plain HTTP on port 80. TLS is the job of your reverse proxy (see [step 2](#2-put-a-tls-proxy-in-front)). Most of this is standard Symfony and Docker. Two parts can cost you data or access if you skip them: the first section below, and [creating the first admin account](#4-create-the-first-admin-account). ## Keep the development stack away from production data > **Warning:** The default `docker-compose.yml` is the development stack. Its entrypoint drops the database and reloads demo fixtures every time the container starts. Against production data, one `docker compose up` with that file wipes the database, and there is no undo. You need both of these safeguards: 1. Keep `COMPOSE_FILE=docker-compose.prod.yml` in the root `.env`. It ships in `.env.dist` and pins every bare `docker compose …` command in the checkout to the production stack, so a plain `docker compose up -d` is safe. 2. Never pass `-f docker-compose.yml` in a production checkout, and never run `docker compose` from a directory without the root `.env`. The `COMPOSE_FILE` pin does nothing in either case. The two stacks also use separate Docker volumes and container names, so they can't end up sharing a database by accident. To promote an existing dev install, see [Moving a dev install to production](#moving-a-dev-install-to-production). ## 1. Configure There are two files, and different programs read them. The root `.env` is read by Docker Compose. It holds the database container's credentials and the `COMPOSE_FILE` pin. Create it with `cp .env.dist .env` and change the passwords. `app/.env.local` is read by Symfony. Create it with at least: ```dotenv APP_ENV=prod APP_SECRET= APP_BASE_URL=https://leave.example.com TRUSTED_PROXIES= TRUSTED_HOSTS='^leave\.example\.com$' DATABASE_URL="mysql://ooo:@db:3306/ooo_db?serverVersion=8.4.4&charset=utf8mb4" MAILER_DSN=smtp://user:pass@smtp.example.com:587 EMAIL_FROM_ADDRESS=noreply@example.com EMAIL_FROM_NAME="Who's OOO" TOTP_ENCRYPTION_KEY= ICAL_SECRET= MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 ``` - The user, password, and database name in `DATABASE_URL` must match the `MYSQL_*` values in the root `.env`. - `APP_BASE_URL` takes no trailing slash. Emails sent from the workers and from scheduled jobs run outside any HTTP request, so they build their links from this value. - `ICAL_SECRET` must be at least 32 characters. `openssl rand -hex 16` gives exactly 32, and the app logs a warning in production when the secret is shorter. > **Warning:** Don't start from a copy of `app/.env`. That file sets `APP_ENV=dev` and points `MAILER_DSN` at Mailpit. Slack variables are optional. See [Configuration](/docs/configuration) for the full list of environment variables. ## 2. Put a TLS proxy in front The app doesn't terminate TLS. Put nginx, Caddy, Traefik, or a cloud load balancer in front of it and let that proxy handle HTTPS. Once the proxy is in place, set `TRUSTED_PROXIES` in `app/.env.local` to the address the proxy connects from. Without it, Symfony ignores the `X-Forwarded-*` headers and treats every request as plain `http`: session cookies lose the `Secure` flag, and absolute URLs built during a request start with `http://`. ```dotenv # One or more comma-separated IPs or CIDRs, covering only the proxy itself. # 172.18.0.1 is an example: use your proxy's address or your compose network gateway. TRUSTED_PROXIES=172.18.0.1 ``` Avoid `REMOTE_ADDR` and `PRIVATE_SUBNETS` unless port 80 is reachable by the proxy and nothing else. If you trust any address other than your proxy, clients can forge their IP, scheme, and host. `docker-compose.prod.yml` publishes port 80 on every interface (IPv4 and IPv6) by default. If the proxy runs on the same host, set `HTTP_BIND_ADDRESS=127.0.0.1` in the root `.env`. Set `TRUSTED_HOSTS` to a regular expression that matches your public host name. Symfony then answers `400` to any request with a different `Host` header. When the variable is empty (the default), every host is accepted. ```dotenv TRUSTED_HOSTS='^leave\.example\.com$' ``` `TRUSTED_HOSTS` doesn't replace `APP_BASE_URL`. You need both. ## 3. Start ```bash docker compose -f docker-compose.prod.yml up -d --build docker compose -f docker-compose.prod.yml exec php bin/console doctrine:migrations:migrate --no-interaction ``` The workers wait for the `messenger_messages` table before they start consuming, so they sit idle until the migrations have run. ## 4. Create the first admin account Production never loads fixtures, and new accounts are created by invitation only. A fresh install therefore has nobody who can log in until you insert an admin row by hand. After that, admins add everyone else from the UI (see [User Invitations](/docs/user-invitations)). > **Warning:** The very first migration inserts an inactive admin, `admin@whoisooo.app`, into every install. Its password hash is public in the repository. Never reactivate it. Delete it with the statement below. If MySQL refuses because other rows reference the account, leave it inactive. ```text DELETE FROM user WHERE email = 'admin@whoisooo.app' AND is_active = 0; ``` Generate an Argon2id hash for the password you want: ```bash docker compose -f docker-compose.prod.yml exec php \ bin/console security:hash-password 'your-password' 'App\Infrastructure\Doctrine\Entity\User' ``` Copy the `Password hash` value. Then open a MySQL shell inside the container and paste the statement below there: ```bash docker compose -f docker-compose.prod.yml exec db mysql -u root -p ooo_db ``` ```text INSERT INTO user ( id, first_name, last_name, email, password, roles, annual_leave_allowance, current_leave_balance, is_active, is_email_notifications_enabled, celebrate_work_anniversary, working_days, backup_codes, is_two_factor_enabled, absence_balance_reset_day, theme_preference, palette_preference, created_at, updated_at ) VALUES ( UUID(), 'Ada', 'Lovelace', 'admin@example.com', '$argon2id$v=19$m=65536,t=4,p=1$REPLACE$WITH_THE_HASH_FROM_ABOVE', '["ROLE_ADMIN"]', 30, 30, 1, 1, 1, '[1, 2, 3, 4, 5]', '[]', 0, MAKEDATE(YEAR(CURRENT_DATE()), 1), 'auto', 'teal', NOW(), NOW() ); ``` > **Warning:** Don't put the hash into a command on your host shell. It contains `$` characters, and the host shell will expand them and corrupt the hash without any error. About the values: - Every listed column is `NOT NULL`. The omitted columns (`profile_image_url`, `birth_date`, `contract_started_at`, `manager_id`, `holiday_calendar_id`, `totp_secret`, `subdivision_code`, and others) are nullable and can be filled in later from the profile page. - `roles` and `working_days` are JSON columns, so keep the quoting exactly as shown. `ROLE_USER` is added at runtime, so `["ROLE_ADMIN"]` is enough. - `is_active` must be `1`. Inactive users can't log in and are hidden from team lists and calendars. - `working_days` lists ISO weekday numbers (`1` is Monday). - `absence_balance_reset_day` is the date the yearly leave balance resets. Most installs use 1 January of the current year. Log in at `https://your-domain/login`, change the password, and turn on two-factor authentication from **Security** in the **Account** section of the sidebar. > **Tip:** A `bin/console app:user:create-admin` command is planned to replace this manual step. Until it exists, the SQL above is the supported path. ## Background workers Emails, auto-approvals, and every scheduled job (including Slack status sync and the weekly digest) go through Symfony Messenger. If nothing consumes the queue, no email goes out and the messages pile up in the `messenger_messages` table. Two services consume it: | Service | Transport | What stops working without it | |---|---|---| | `worker-async` | `async` | Invitation emails, leave request notification emails, auto-approval messages | | `worker-scheduler` | `scheduler_default` | Leave request auto-approve (5 min), Slack status sync (20 min), `app:feed:sync` for the in-app "What's new" feed (6 h), password reset token cleanup and leave balance reset (daily), public holiday calendar sync (yearly) | | `worker-scheduler` | `scheduler_weekly_digest` | The Slack weekly digest | They are split because the scheduler must not restart on a timer. Schedules keep no state, so a restarted scheduler computes the next run from the current time and can skip a job that fell due while it was down. `worker-async` restarts itself every hour (`WORKER_TIME_LIMIT`); `worker-scheduler` doesn't. Check that both are running: ```bash docker compose -f docker-compose.prod.yml logs worker-async worker-scheduler ``` A `messenger_messages` table that keeps growing means a worker has stopped. Messages that fail repeatedly move to the `failed` queue instead of being thrown away: ```bash docker compose -f docker-compose.prod.yml exec php bin/console messenger:failed:show docker compose -f docker-compose.prod.yml exec php bin/console messenger:failed:retry ``` ## Test your mail configuration Symfony's `mailer:test` command sends from `from@example.org` unless told otherwise, and many SMTP providers reject that address. Pass your own sender: ```bash docker compose -f docker-compose.prod.yml exec php bin/console mailer:test you@example.com --from noreply@example.com ``` ## Application settings storage [App Settings](/docs/configuration#application-settings) are stored in a YAML file on the `whoisooo-prod_settings` volume, at `/var/www/settings/app_setting.yaml`. The `php` container and both workers mount the same volume, so a change saved in the UI reaches the scheduled jobs on their next run and survives `up --build`. On first start, the containers copy the defaults from the image onto the empty volume. After that, the file on the volume wins and later images never overwrite it. `docker-compose.prod.yml` sets `APP_SETTINGS_FILE` for these containers, which overrides any value in `app/.env.local`. If you kept your own settings file somewhere else, copy it onto the volume once: ```bash docker compose -f docker-compose.prod.yml cp my-settings.yaml php:/var/www/settings/app_setting.yaml docker compose -f docker-compose.prod.yml exec php chown www-data:www-data /var/www/settings/app_setting.yaml ``` ## Upgrading an existing install Deactivated users can't log in, and an active session ends on the user's next request after deactivation. The `is_active` column was added without a backfill, so on older installs some people may still have it set to `0`. Before you upgrade, run this read-only query to list the accounts that will be locked out: ```sql SELECT u.email, u.created_at FROM user u LEFT JOIN invitation i ON i.user_id = u.id WHERE u.is_active = 0 AND i.id IS NULL; ``` Reactivate the people on that list who should keep access. Anyone you miss can't log in after the upgrade until an admin reactivates them. > **Warning:** Don't fix this with a blanket `UPDATE user SET is_active = 1`, because that also reactivates accounts that were deactivated on purpose. If the list includes `admin@whoisooo.app`, delete it as described in [Create the first admin account](#4-create-the-first-admin-account). ## Backups Nothing is backed up for you. Application data lives in the `whoisooo-prod_mysql_prod` volume, uploaded profile images in `whoisooo-prod_uploads`, and App Settings in `whoisooo-prod_settings`. Database: ```bash docker compose -f docker-compose.prod.yml exec -T db \ sh -c 'exec mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" ooo_db' \ | gzip > backup-$(date +%F).sql.gz ``` Keep the single quotes around the `sh -c` argument. `$MYSQL_ROOT_PASSWORD` is only set inside the container, so it has to expand there. Without the wrapper, your host shell expands it first, the password comes out empty, and the dump fails. Profile images: ```bash docker run --rm -v whoisooo-prod_uploads:/data -v "$PWD":/backup alpine \ tar czf /backup/uploads-$(date +%F).tar.gz -C /data . ``` App Settings: ```bash docker compose -f docker-compose.prod.yml cp php:/var/www/settings/app_setting.yaml app_setting-$(date +%F).yaml ``` ## Moving a dev install to production The development and production stacks use different Docker volumes (`who-is-out-of-office_mysql` and `whoisooo-prod_mysql_prod`) and different container names. If you switch a dev install to `docker-compose.prod.yml`, it starts with an empty database. You can't point the production stack at the dev volume either, because MySQL won't apply new `MYSQL_*` credentials to a data directory that is already initialised. Move the data with a dump and restore. > **Warning:** These are the only commands on this page that use `-f docker-compose.yml`. Starting the dev `php` container drops the database, so if the dev stack is already stopped, start only its `db` service (`docker compose -f docker-compose.yml up -d db`) before you dump. ```bash # 1. Dump the dev database (it has no root password), then stop the dev stack. # Both stacks publish port 80, so they cannot run side by side. docker compose -f docker-compose.yml exec -T db \ sh -c 'exec mysqldump -u root ooo_db' > dev-dump.sql docker compose -f docker-compose.yml stop # 2. Start only the production database and restore the dump into it docker compose -f docker-compose.prod.yml up -d --wait db docker compose -f docker-compose.prod.yml exec -T db \ sh -c 'exec mysql -u root -p"$MYSQL_ROOT_PASSWORD" ooo_db' < dev-dump.sql # 3. Start the rest of the stack and bring the schema up to date docker compose -f docker-compose.prod.yml up -d --build docker compose -f docker-compose.prod.yml exec php bin/console doctrine:migrations:migrate --no-interaction ``` Restore before you migrate. The dump drops and recreates every table, including `doctrine_migration_versions`, so migrations run first would be undone by the restore. Restoring before the workers start also keeps them from consuming queued dev messages. > **Warning:** The dump contains the dev fixture accounts, including `admin@whoisooo.app`, and every fixture user has the password `123`. Delete those accounts or change their passwords before the site is reachable. In the dev stack, profile images sit in `app/public/uploads` on the host, not in a volume. Copy them into the production volume with: ```bash docker run --rm -v whoisooo-prod_uploads:/data -v "$PWD/app/public/uploads":/src:ro alpine \ cp -a /src/. /data/ ``` --- # Public Holidays > Import and manage public holiday calendars that affect leave request workday calculations ## Overview Public holidays are subtracted from workday calculations when users submit leave requests. If a user requests five days off and one of those days is a public holiday, only four workdays are deducted from their balance. Holidays are organized into **calendars** by country and year. Each calendar can be toggled active or inactive, and users are assigned to a calendar in their profile. ## Import via the admin panel 1. Go to **Settings → Public Holidays** in the sidebar. 2. Click **Add Calendar**. 3. Select the **Country** and **Year**. 4. Click **Import Calendar** to fetch the holidays from the [Nager.Date](https://date.nager.at) API. ![Public Holidays import dialog showing country and year dropdown selectors](/assets/images/docs/holiday-import.png) *Import public holidays by selecting a country and year* ## Import via the command line The same import is available from the console. It takes a country code, a country name, and a year: ```bash php bin/console app:holiday:import DE Germany 2026 ``` ## Manage calendars After importing, the calendar appears in the Public Holidays list. From here you can: - **Show**: see all holidays in the calendar with their dates, names, and regions. - **Toggle Active**: inactive calendars are ignored in workday calculations. - **Sync Now**: re-fetch the current year's holidays (useful if the API data was updated). Calendars can't be deleted; deactivate a calendar you no longer need instead. ![Public Holidays list showing multiple country calendars with active status and holiday counts](/assets/images/docs/holiday-list.png) *Manage imported holiday calendars* ## Yearly sync Every active calendar is synced automatically at 02:00 on 1 January: the app re-imports that year's holidays from Nager.Date, so workday calculations stay accurate without any manual step. The job runs in the `worker-scheduler` service, so make sure it is running (see [Background workers](/docs/production-deployment#background-workers)). ## Regional subdivisions Some countries have holidays that only apply to specific regions (e.g. state-level holidays in Germany or the US). The app handles this with two concepts: - **Global holidays** — apply to everyone in that country (e.g. Christmas Day in Germany). - **Regional holidays** — apply only to users in a specific subdivision (e.g. Assumption Day in Bavaria / `DE-BY`). When a holiday is imported, it stores whether it is global and which subdivision codes it applies to. The workday calculation logic: 1. Global holidays always count. 2. Regional holidays count only if the user's subdivision matches. ![Holiday calendar details showing dates, names, and scope for global and regional holidays](/assets/images/docs/holiday-preview.png) *Global vs regional holidays with subdivision codes* ## Assign calendars to users Each user must be assigned a public holiday calendar for it to affect their leave calculations. 1. Go to **My Organization** and edit the user. 2. Under **Holiday Calendar**, select the appropriate calendar from the dropdown. 3. If the user is in a region with specific holidays, also set their **Subdivision** (e.g. `DE-BY` for Bavaria). 4. **Save**. If no calendar is assigned, no public holidays are subtracted from that user's leave requests. > **Tip:** When a user's subdivision is set, they get both global holidays and holidays specific to their region. Without a subdivision, only global holidays apply. --- # Roles & Permissions > Understand the access control model and manage who can do what ## Roles overview Who's OOO uses a simple three-role model: | Role | Description | |---|---| | **Admin** | Manage all users and leave types. Approve or reject any leave request. Import public holiday calendars. Configure App Settings and integrations. | | **Manager** | View and manage direct reports. Approve or reject leave requests from their team. See team-scoped dashboard data. | | **User** | Submit leave requests. View personal leave balance. Update profile, connected services, and security settings. | Admins and managers both see the **My Team** section of the sidebar. Only admins see **Settings** and **My Organization**. Every account is a User; Admin and Manager are added on top. A user can hold more than one role. Someone who runs the app and also has direct reports, for example, can be both Admin and Manager. ## Assigning roles Roles are set on the **Security** tab of the user form when [creating or inviting a user](/docs/user-invitations). Admins can change them later from **My Organization** by editing the user. A user can have the Manager role without any direct reports — they will not see any pending approvals until reports are assigned. ## Two-factor authentication Every user can protect their account with two-factor authentication. Open **Security** in the **Account** section of the sidebar and click **Enable 2FA**. Setup asks for your current password and a 6-digit code from an authenticator app, and gives you 8 recovery codes to store somewhere safe. Recovery codes can be regenerated from the same page. Admins can't enforce or reset 2FA for other users. --- # Slack Integration > Connect Who's OOO to Slack for leave request approvals, weekly digests, personal notifications, and automatic status updates ## Overview The Slack integration adds four capabilities: - **Approval channel**: new leave requests are posted to a shared channel with Approve / Reject buttons. Managers act directly from Slack. - **Weekly digest**: once a week, at a day and time you choose, a summary is posted: who's out this week, birthdays, and work anniversaries. - **Private DMs**: users receive a personal Slack message when their leave request is approved or rejected from Slack, or approved automatically. - **Status sync**: while someone is on approved leave, their Slack status shows it (e.g. 🌴 "Vacation until Apr 18") and is cleared when they're back. Requires a paid Slack workspace. ## Create a Slack app 1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App → From scratch**. 2. Name the app (e.g. "Who's OOO") and select your workspace. 3. Under **OAuth & Permissions → Scopes → Bot Token Scopes**, add `chat:write`. 4. Click **Install to Workspace** and authorize. 5. Copy the **Bot User OAuth Token** (starts with `xoxb-`). ## Environment variables Add these variables to `app/.env.local`: ```dotenv SLACK_DSN=slack://TOKEN@default?channel=CHANNEL SLACK_SIGNING_SECRET=your-signing-secret SLACK_AR_APPROVE_CHANNEL_ID=C0123APPROVAL SLACK_AR_HR_DIGEST_CHANNEL_ID=C0123DIGEST ``` | Variable | Where to find it | |---|---| | `SLACK_DSN` | Bot token from the previous step. Replace `TOKEN` with the `xoxb-` value. | | `SLACK_SIGNING_SECRET` | **Basic Information → App Credentials → Signing Secret** | | `SLACK_AR_APPROVE_CHANNEL_ID` | Right-click the approval channel in Slack → **View channel details** → copy the Channel ID at the bottom. | | `SLACK_AR_HR_DIGEST_CHANNEL_ID` | Same steps for the digest channel. | > **Tip:** Channel IDs look like `C04ABCD1234`. Don't use the channel name — it won't work. Every request Slack sends to the app is checked against `SLACK_SIGNING_SECRET`, so only your Slack app can call it. While the secret is empty, every request is rejected. ## Enable interactivity For the Approve / Reject buttons to work, Slack needs to send button clicks back to your app. 1. In your Slack App settings, go to **Interactivity & Shortcuts**. 2. Toggle **Interactivity** to **On**. 3. Set the **Request URL** to: ``` https://your-domain.com/api/slack/interactive-endpoint ``` 4. Click **Save Changes**. When a manager clicks a button, the app verifies the Slack signature, updates the leave request, and edits the original Slack message to show the outcome. For **local development**, use [ngrok](https://ngrok.com/) to expose your local server: ```bash ngrok http 80 ``` Then set the Request URL to the ngrok HTTPS URL, e.g.: ``` https://abc123.ngrok-free.app/api/slack/interactive-endpoint ``` > **Warning:** The ngrok URL changes every time you restart it (on the free plan). Update the Slack Request URL accordingly. ## Invite the bot to channels The bot must be a member of both the approval and digest channels to post messages. In each channel, type: ``` /invite @Who's OOO ``` Or open the channel settings and add the app under **Integrations → Apps**. ## Connect individual users Each user can link their Slack account to receive personal DMs about their leave requests. 1. In the app, go to **Profile Settings** (in the **Account** section of the sidebar). 2. On the **Connected Services** card, click **Configure** next to Slack. 3. Enter your **Slack Member ID** and save. To find your Slack Member ID: click your profile picture in Slack → **Profile** → click the three-dot menu → **Copy Member ID**. DMs are sent when a request is approved or rejected with the buttons in the approval channel, and when a request is auto-approved. Approvals and rejections made in the web app are sent by email. ## Admin status check Admins can verify which users have connected their Slack accounts. Go to **My Organization** — each user's row shows a Slack status indicator. A green icon means the user has linked their Slack Member ID; grey means they haven't. ## Weekly digest The weekly digest posts to the digest channel with: - Who's out this week (approved leave and public holidays) - Birthdays - Work anniversaries By default it runs every Monday at 08:15 UTC. Admins change the day, time, and timezone under **Settings → App Settings → Weekly Digest Schedule**. Changes are picked up on the next scheduler tick, without a restart. The digest runs on its own scheduler transport, so the `worker-scheduler` service must be running (it is part of both Docker stacks). Without Docker, run: ```bash php bin/console messenger:consume scheduler_default scheduler_weekly_digest ``` You can trigger it manually for testing: ```bash # Development stack docker exec -it app_ooo_php php bin/console slack:weekly_digest # Production stack docker compose -f docker-compose.prod.yml exec php bin/console slack:weekly_digest ``` > **Tip:** If nobody is out and there are no birthdays or anniversaries, the digest posts a short "Good news!" message instead. ## Slack status sync While a user is on approved leave, the app sets their Slack status to the leave type's emoji and a text like "Vacation until Apr 18", and clears it when the leave ends or is cancelled. > **Warning:** Status sync needs a paid Slack workspace. Free plans don't allow an app to set another user's status. The sync job runs every 20 minutes. Each leave request carries an internal sync flag, so a status is never set twice and the job catches up on its own after downtime. ### Admin setup 1. Add these variables to `app/.env.local`: ```dotenv SLACK_CLIENT_ID="your-slack-app-client-id" SLACK_CLIENT_SECRET="your-slack-app-client-secret" SLACK_TOKEN_ENCRYPTION_KEY="base64-encoded-32-byte-key" ``` Generate the encryption key with: ```bash php -r "echo base64_encode(sodium_crypto_secretbox_keygen());" ``` The key encrypts the admin's OAuth token at rest. Keep it secret. If you change it, you have to authorize status sync again. 2. In the Slack app settings, add this OAuth redirect URL: ``` https://your-domain.com/app/settings/slack-status-sync/oauth/callback ``` 3. Under **OAuth & Permissions → User Token Scopes**, add `users.profile:write`. 4. In Who's OOO, open **Settings → App Settings** and turn on **Enable Slack status auto-sync**. 5. Open **Settings → Integrations** and click **Authorize status sync** on the Status Sync card. Slack asks you to grant permission. The person doing this must be an Owner or Admin of the Slack workspace. 6. Once authorized, the card shows the sync as active. > **Warning:** Adding a scope means reinstalling the Slack app in your workspace. That issues a new Bot User OAuth Token, so update `SLACK_DSN` afterwards. Each user needs their Slack Member ID in their profile (see [Connect individual users](#connect-individual-users)) for their status to be set. ### Leave type emojis Each leave type can have its own Slack emoji, such as `:palm_tree:` or `:face_with_thermometer:`. Set it in the **Slack status emoji** field when editing a [leave type](/docs/leave-types). Leave it blank and you get `:calendar:`. ### User opt-out Users can turn status sync off for their own account: **Profile Settings → Connected Services → Slack → Configure**, then untick **Auto-set my Slack status when I'm on leave**. ### Revoking access An admin can revoke the authorization at any time with **Revoke authorization** on the Status Sync card under **Settings → Integrations**. That turns the feature off for everyone at once. If the admin's token stops working (for example, the person who authorized it was removed from Slack), the app notices and turns the feature off by itself. --- # User Invitations > Invite users to the app and walk through the account activation flow ## Overview New users are added by an admin from **My Organization**. When a user is created, an invitation email is sent automatically with a token-based activation link. The invited user clicks the link, sets their name and password, and their account is active. > **Tip:** On a fresh production install there is no one to send invitations yet. Create the first admin by hand as described in [Production Deployment](/docs/production-deployment#4-create-the-first-admin-account). ## Create and invite a user 1. Go to **My Organization** in the **Organization** section of the sidebar. 2. Click **Add User**. 3. Fill in the form: - **Profile** tab: the user's work **Email**. - **Employee of record** tab: annual leave allowance and current balance (pre-filled from [App Settings](/docs/configuration#application-settings)), absence balance reset day, **Manager** (establishes the team relationship), contract start date, and working days. - **Security** tab: **Roles**: Admin, Manager, or User (see [Roles & Permissions](/docs/roles-permissions)). 4. Click **Save**. The invitation email is sent right after saving. Until the user accepts it, their account is inactive: they can't log in and don't appear in the calendar or organization chart. ![Create User form showing the profile tab with email field](/assets/images/docs/invite-create-user.png) *Create a new user from the My Organization page* ## Invitation email The invited user receives an email with an activation link containing a unique token. The link stays valid until the user accepts it or an admin resends the invitation. In local development, emails are caught by Mailpit at [http://localhost:8025](http://localhost:8025) — no real emails are sent. ![Invitation email in Mailpit showing the activation link](/assets/images/docs/invite-email.png) *Invitation email as seen in Mailpit* > **Tip:** Emails are sent by a background worker. If an invitation never arrives, check that the `worker-async` service is running (or, without Docker, `php bin/console messenger:consume async`). See [Background workers](/docs/production-deployment#background-workers). ## Accept the invitation When the user clicks the activation link, they're taken to a registration form: 1. Enter **First name** and **Last name**. 2. Set a **Password**. 3. Optionally enter **Birth date** (used for the birthday section on the dashboard). 4. Click **Activate**. The account is now active and the user can log in. ![Account activation form with fields for name, date of birth, and password](/assets/images/docs/invite-accept.png) *The account activation form* ## After activation Once activated, the user should: - Complete their profile in **Profile Settings** (profile image and other personal details). - Add their Slack Member ID under **Profile Settings → Connected Services** to get personal Slack notifications. - Copy the calendar feed URL from **Connected Services** to see their leave in their calendar app. - Turn on 2FA from **Security** in the **Account** section of the sidebar. ## Resend an invitation or reset a password Both actions are on the user's detail and edit pages in **My Organization**: - **Resend Invitation**: shown while the user hasn't accepted their invitation yet. It sends a fresh activation email; the link in the old email stops working. - **Reset Password**: shown for active users (not on your own account). It emails a password reset link that expires after 1 hour.