Unactivated, Notifio is one screen: an email field, a token field, a button. It is 130 lines of React in the Electron renderer, and it is the only thing between somebody who has paid and the app they paid for, so it has had more attention per line than anything else in the project.
Two of the decisions in it are good and one of them is a wart I only looked at properly while writing this.
The checks that happen before the network does
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError("");
const trimmedEmail = email.trim();
const trimmedToken = token.trim().toUpperCase();
if (!trimmedEmail || !trimmedEmail.includes("@")) {
setError("Enter a valid email address.");
return;
}
if (!trimmedToken || trimmedToken.length < 10) {
setError("Enter your activation token.");
return;
}
setLoading(true);
const { ok, data } = await activateLicense(trimmedEmail, trimmedToken);
setLoading(false);
if (!ok) {
setError(data.error || "Activation failed. Please try again.");
return;
}
onActivated();
}
Three guards, and none of them is the real validation. activateLicense posts to a local HTTP endpoint in the Electron main process, that endpoint repeats the same two checks, and only then does it ask our server whether the pair exists:
app.post('/api/license/activate', async (req, res) => {
const { email, token } = req.body;
if (!email || !email.includes('@')) {
return res.status(400).json({ error: 'Valid email is required.' });
}
if (!token || token.length < 10) {
return res.status(400).json({ error: 'Valid token is required.' });
}
const normalEmail = email.trim().toLowerCase();
const normalToken = token.trim().toUpperCase();
// Validate against server. A licence is only wrong if the server says so:
// telling someone to check their activation email because their wifi was
// down sends them looking for a problem that is not there.
const check = await monitor.checkLicense(normalEmail, normalToken);
if (!check.ok && check.reason === 'unreachable') {
return res.status(503).json({ error: 'Could not reach notifio.app to check that licence. ...' });
}
if (!check.ok) {
return res.status(403).json({ error: 'Invalid email or token. Please check your activation email.' });
}
// ...
});
Three layers, two of which run on the user's own machine. That looks like one copy too many until you notice that the renderer is not the only thing that can reach a local server listening on 127.0.0.1, so the main process cannot treat its own UI as trusted input. And the remote answer is the only one that decides anything: note the 503 for unreachable, kept distinct from the 403 for wrong, because a failure to ask is not a no.
So why does the renderer check at all?
An activation screen is the one place in the app where the user is most likely to be in a bad mood already. They have paid, they have switched windows to find the email, they have copied something out of it. A round trip that comes back to say the field is empty is the worst available answer, because it is slow, it is network-dependent, and the user already knows.
The length check is the one that earns its keep. Tokens look like NTFIO-XXXX-XXXX-XXXX, which is eighteen characters, so anything under ten is not a token that was ever issued: it is half a paste, or a confirmation number, or the word "token". Rejecting it locally turns a round trip into an instant answer. Note that the floor is ten and not eighteen, and it does not check the alphabet either, deliberately: a client that knows the exact token format has to be updated when the format changes, and the server would still be the one to say no. A loose floor catches the mistakes people actually make without encoding anything.
The email check is one character. includes("@") catches the genuinely empty and the obviously-not-an-address, and leaves everything else to the layers behind it, in the same spirit as the deliberately loose regex on our contact form. The failure mode worth avoiding is not "a malformed address reached the server". It is a paying customer with a perfectly valid address that our regex dislikes.
Uppercasing at submit, not while typing
const trimmedToken = token.trim().toUpperCase();
The token alphabet has no O, no 0, no I and no 1, which I wrote about in the post on why 32 characters makes the maths safe. It is also uppercase, and the server uppercases what it receives before looking it up.
Notice where the client transform is: in the submit handler, not in onChange. Uppercasing on every keystroke means writing back into controlled state mid-edit, which is how you get a cursor that jumps to the end when someone fixes a character in the middle. Doing it in text-transform: uppercase has the opposite failure: the field looks uppercase and submits whatever was typed, so the only consistent thing about it is that it is lying to you.
Transforming at the boundary leaves the input alone while it is being edited and normalises once, at the moment the value stops being a half-typed string and becomes a request.
<input
id="token"
placeholder="NTFIO-XXXX-XXXX-XXXX"
value={token}
onChange={(e) => setToken(e.target.value)}
autoComplete="off"
spellCheck={false}
style={{ fontFamily: "monospace" }}
/>
spellCheck={false} and autoComplete="off" are both there for the same reason: a password manager or a spell checker offering suggestions over a field full of random characters is noise at the worst moment. Monospace is so that a user comparing the field against their email can do it character by character without counting.
The email field above it does the opposite, autoComplete="email", because that one is a real email address and the browser knows it better than the user remembers it.
The link that asks where it should point
const [pricingLink, setPricingLink] = useState("https://notifio.app/pricing");
useEffect(() => {
getServerUrl().then(({ data }) => {
setPricingLink(`${data.url}/pricing`);
});
}, []);
The renderer has no preload script and no build-time environment variables. It talks to a local HTTP server in the main process, which is a design I have written about in our Electron renderer has no preload script, and one of those endpoints exists purely to answer this question:
app.get('/api/server-url', (req, res) => {
const url = (process.env.NOTIFIO_SERVER_URL || 'https://notifio.app').replace(/\/$/, '');
res.json({ url });
});
So a development build points the "get a licence" link at a local Next.js instance, and a packaged build points it at production, with no conditional in the component and no isDev reaching the renderer. The trailing slash strip is there because the caller builds the URL with template interpolation, and https://notifio.app//pricing is the kind of link that works everywhere except in a screenshot.
The useState default is the interesting bit. It is the production URL, hardcoded, so the link is already correct before the request resolves. That means the component renders a usable link on frame one, and the fetch only ever corrects it for the developer. The same string is therefore written down twice, once here and once as the server's fallback, and the one on the server is the one that matters.
I think that duplication is worth it for the first render, but it is duplication, and I would not defend a third copy.
The wart
<p className="mt-5 text-center text-[13px] text-[#525252]">
Don't have a license?{" "}
<a href={pricingLink} className="text-[#a3a3a3] underline">
Get one for £20
</a>
</p>
That price is a string literal in a desktop app.
On the website, the same number comes out of one constant:
export const PRICE_CURRENCY = "gbp";
export const LICENSE_PRICE_PENCE = 2000;
which is the amount Stripe charges, the figure in the page's structured data, and the input to the currency localisation that shows a visitor in Berlin an approximate euro price on notifio.app/pricing. One number, one source, several renderings.
The app does not participate in any of that, and the consequences split cleanly into one that does not matter and one that does.
The one that does not matter: the app says pounds to everybody, including people who will see euros on the website. That is defensible, because pounds is what the card is actually charged, so the app is quoting the real figure and the website is quoting a converted approximation of it.
The one that does: a price change means shipping a new build of a desktop app with no auto-update. The website would update in a deploy and the app would keep saying £20 until every user re-downloaded it. That is not a theoretical risk, it is the reason the website derives it from a constant in the first place, and the app is the only surface that opted out.
The fix is small and obvious, which is why it is embarrassing rather than interesting: the component already makes an HTTP call to ask where the website is. It could ask what the licence costs in the same breath, and the endpoint it is already calling is in the same file as the one that would answer. I am writing it down here partly so I actually do it.
Try the screen
The installers are at notifio.app/download, the price this post is about is on notifio.app/pricing, and the activation steps, including what to do when the email with the token has gone missing, are at notifio.app/help.
If you open the app without a licence, the screen in this post is the first thing you get. The field with the monospace font is the one that has had the arguments.
Top comments (0)