2026-05-19 00:30:20 +02:00
|
|
|
'use client';
|
|
|
|
|
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
import { CountryPicker } from '@/components/country-picker';
|
2026-05-21 00:26:44 +02:00
|
|
|
import { Input, Label } from '@/components/input';
|
2026-05-19 00:30:20 +02:00
|
|
|
import { Logo } from '@/components/logo';
|
|
|
|
|
import { Button } from '@/components/ui/button';
|
2026-05-21 00:26:44 +02:00
|
|
|
import { apiFetch, apiUrl } from '@/lib/api';
|
|
|
|
|
import Link from 'next/link';
|
|
|
|
|
import { useEffect, useState } from 'react';
|
|
|
|
|
|
|
|
|
|
const ERROR_COPY: Record<string, string> = {
|
|
|
|
|
google_failed: 'Google sign-in could not be completed. Please try again.',
|
|
|
|
|
google_state: 'Google sign-in expired or was interrupted. Please try again.',
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
github_failed: 'GitHub sign-in could not be completed. Please try again.',
|
|
|
|
|
github_state: 'GitHub sign-in expired or was interrupted. Please try again.',
|
2026-05-21 23:41:19 +02:00
|
|
|
invalid_phone: 'That phone number does not look right. Check the country and number.',
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
rate_limited: 'Too many requests. Wait a few minutes and try again.',
|
|
|
|
|
sms_request_failed: 'Could not send the SMS. Check the number and try again.',
|
|
|
|
|
invalid_or_expired_code: 'That code has expired. Request a new one.',
|
|
|
|
|
invalid_code: 'Wrong code. Check the SMS and try again.',
|
|
|
|
|
too_many_attempts: 'Too many wrong attempts. Request a new code.',
|
|
|
|
|
sms_verify_failed: 'Could not verify the code. Try again.',
|
2026-05-21 00:26:44 +02:00
|
|
|
};
|
2026-05-19 00:30:20 +02:00
|
|
|
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
// Country dial codes for the phone-login picker. ~150 entries — every country
|
|
|
|
|
// with a non-trivial diaspora. Sorted alphabetically by name. Switzerland is
|
|
|
|
|
// the default (Swiss-built product, Swiss Twilio sender number).
|
2026-05-21 23:41:19 +02:00
|
|
|
const COUNTRIES: { code: string; name: string; dial: string }[] = [
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'AL', name: 'Albania', dial: '+355' },
|
|
|
|
|
{ code: 'DZ', name: 'Algeria', dial: '+213' },
|
|
|
|
|
{ code: 'AD', name: 'Andorra', dial: '+376' },
|
|
|
|
|
{ code: 'AO', name: 'Angola', dial: '+244' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'AR', name: 'Argentina', dial: '+54' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'AM', name: 'Armenia', dial: '+374' },
|
|
|
|
|
{ code: 'AW', name: 'Aruba', dial: '+297' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'AU', name: 'Australia', dial: '+61' },
|
|
|
|
|
{ code: 'AT', name: 'Austria', dial: '+43' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'AZ', name: 'Azerbaijan', dial: '+994' },
|
|
|
|
|
{ code: 'BS', name: 'Bahamas', dial: '+1' },
|
|
|
|
|
{ code: 'BH', name: 'Bahrain', dial: '+973' },
|
|
|
|
|
{ code: 'BD', name: 'Bangladesh', dial: '+880' },
|
|
|
|
|
{ code: 'BB', name: 'Barbados', dial: '+1' },
|
|
|
|
|
{ code: 'BY', name: 'Belarus', dial: '+375' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'BE', name: 'Belgium', dial: '+32' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'BZ', name: 'Belize', dial: '+501' },
|
|
|
|
|
{ code: 'BJ', name: 'Benin', dial: '+229' },
|
|
|
|
|
{ code: 'BM', name: 'Bermuda', dial: '+1' },
|
|
|
|
|
{ code: 'BT', name: 'Bhutan', dial: '+975' },
|
|
|
|
|
{ code: 'BO', name: 'Bolivia', dial: '+591' },
|
|
|
|
|
{ code: 'BA', name: 'Bosnia & Herzegovina', dial: '+387' },
|
|
|
|
|
{ code: 'BW', name: 'Botswana', dial: '+267' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'BR', name: 'Brazil', dial: '+55' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'BN', name: 'Brunei', dial: '+673' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'BG', name: 'Bulgaria', dial: '+359' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'BF', name: 'Burkina Faso', dial: '+226' },
|
|
|
|
|
{ code: 'KH', name: 'Cambodia', dial: '+855' },
|
|
|
|
|
{ code: 'CM', name: 'Cameroon', dial: '+237' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'CA', name: 'Canada', dial: '+1' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'CV', name: 'Cape Verde', dial: '+238' },
|
|
|
|
|
{ code: 'KY', name: 'Cayman Islands', dial: '+1' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'CL', name: 'Chile', dial: '+56' },
|
|
|
|
|
{ code: 'CN', name: 'China', dial: '+86' },
|
|
|
|
|
{ code: 'CO', name: 'Colombia', dial: '+57' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'CR', name: 'Costa Rica', dial: '+506' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'HR', name: 'Croatia', dial: '+385' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'CY', name: 'Cyprus', dial: '+357' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'CZ', name: 'Czechia', dial: '+420' },
|
|
|
|
|
{ code: 'DK', name: 'Denmark', dial: '+45' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'DO', name: 'Dominican Republic', dial: '+1' },
|
|
|
|
|
{ code: 'EC', name: 'Ecuador', dial: '+593' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'EG', name: 'Egypt', dial: '+20' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'SV', name: 'El Salvador', dial: '+503' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'EE', name: 'Estonia', dial: '+372' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'ET', name: 'Ethiopia', dial: '+251' },
|
|
|
|
|
{ code: 'FJ', name: 'Fiji', dial: '+679' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'FI', name: 'Finland', dial: '+358' },
|
|
|
|
|
{ code: 'FR', name: 'France', dial: '+33' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'GE', name: 'Georgia', dial: '+995' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'DE', name: 'Germany', dial: '+49' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'GH', name: 'Ghana', dial: '+233' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'GR', name: 'Greece', dial: '+30' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'GT', name: 'Guatemala', dial: '+502' },
|
|
|
|
|
{ code: 'HN', name: 'Honduras', dial: '+504' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'HK', name: 'Hong Kong', dial: '+852' },
|
|
|
|
|
{ code: 'HU', name: 'Hungary', dial: '+36' },
|
|
|
|
|
{ code: 'IS', name: 'Iceland', dial: '+354' },
|
|
|
|
|
{ code: 'IN', name: 'India', dial: '+91' },
|
|
|
|
|
{ code: 'ID', name: 'Indonesia', dial: '+62' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'IR', name: 'Iran', dial: '+98' },
|
|
|
|
|
{ code: 'IQ', name: 'Iraq', dial: '+964' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'IE', name: 'Ireland', dial: '+353' },
|
|
|
|
|
{ code: 'IL', name: 'Israel', dial: '+972' },
|
|
|
|
|
{ code: 'IT', name: 'Italy', dial: '+39' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'CI', name: 'Ivory Coast', dial: '+225' },
|
|
|
|
|
{ code: 'JM', name: 'Jamaica', dial: '+1' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'JP', name: 'Japan', dial: '+81' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'JO', name: 'Jordan', dial: '+962' },
|
|
|
|
|
{ code: 'KZ', name: 'Kazakhstan', dial: '+7' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'KE', name: 'Kenya', dial: '+254' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'XK', name: 'Kosovo', dial: '+383' },
|
|
|
|
|
{ code: 'KW', name: 'Kuwait', dial: '+965' },
|
|
|
|
|
{ code: 'KG', name: 'Kyrgyzstan', dial: '+996' },
|
|
|
|
|
{ code: 'LA', name: 'Laos', dial: '+856' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'LV', name: 'Latvia', dial: '+371' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'LB', name: 'Lebanon', dial: '+961' },
|
|
|
|
|
{ code: 'LY', name: 'Libya', dial: '+218' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'LI', name: 'Liechtenstein', dial: '+423' },
|
|
|
|
|
{ code: 'LT', name: 'Lithuania', dial: '+370' },
|
|
|
|
|
{ code: 'LU', name: 'Luxembourg', dial: '+352' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'MO', name: 'Macau', dial: '+853' },
|
|
|
|
|
{ code: 'MK', name: 'North Macedonia', dial: '+389' },
|
|
|
|
|
{ code: 'MG', name: 'Madagascar', dial: '+261' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'MY', name: 'Malaysia', dial: '+60' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'MV', name: 'Maldives', dial: '+960' },
|
|
|
|
|
{ code: 'MT', name: 'Malta', dial: '+356' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'MX', name: 'Mexico', dial: '+52' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'MD', name: 'Moldova', dial: '+373' },
|
|
|
|
|
{ code: 'MC', name: 'Monaco', dial: '+377' },
|
|
|
|
|
{ code: 'MN', name: 'Mongolia', dial: '+976' },
|
|
|
|
|
{ code: 'ME', name: 'Montenegro', dial: '+382' },
|
|
|
|
|
{ code: 'MA', name: 'Morocco', dial: '+212' },
|
|
|
|
|
{ code: 'MZ', name: 'Mozambique', dial: '+258' },
|
|
|
|
|
{ code: 'MM', name: 'Myanmar', dial: '+95' },
|
|
|
|
|
{ code: 'NA', name: 'Namibia', dial: '+264' },
|
|
|
|
|
{ code: 'NP', name: 'Nepal', dial: '+977' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'NL', name: 'Netherlands', dial: '+31' },
|
|
|
|
|
{ code: 'NZ', name: 'New Zealand', dial: '+64' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'NI', name: 'Nicaragua', dial: '+505' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'NG', name: 'Nigeria', dial: '+234' },
|
|
|
|
|
{ code: 'NO', name: 'Norway', dial: '+47' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'OM', name: 'Oman', dial: '+968' },
|
|
|
|
|
{ code: 'PK', name: 'Pakistan', dial: '+92' },
|
|
|
|
|
{ code: 'PS', name: 'Palestine', dial: '+970' },
|
|
|
|
|
{ code: 'PA', name: 'Panama', dial: '+507' },
|
|
|
|
|
{ code: 'PG', name: 'Papua New Guinea', dial: '+675' },
|
|
|
|
|
{ code: 'PY', name: 'Paraguay', dial: '+595' },
|
|
|
|
|
{ code: 'PE', name: 'Peru', dial: '+51' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'PH', name: 'Philippines', dial: '+63' },
|
|
|
|
|
{ code: 'PL', name: 'Poland', dial: '+48' },
|
|
|
|
|
{ code: 'PT', name: 'Portugal', dial: '+351' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'PR', name: 'Puerto Rico', dial: '+1' },
|
|
|
|
|
{ code: 'QA', name: 'Qatar', dial: '+974' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'RO', name: 'Romania', dial: '+40' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'RU', name: 'Russia', dial: '+7' },
|
|
|
|
|
{ code: 'RW', name: 'Rwanda', dial: '+250' },
|
|
|
|
|
{ code: 'SM', name: 'San Marino', dial: '+378' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'SA', name: 'Saudi Arabia', dial: '+966' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'SN', name: 'Senegal', dial: '+221' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'RS', name: 'Serbia', dial: '+381' },
|
|
|
|
|
{ code: 'SG', name: 'Singapore', dial: '+65' },
|
|
|
|
|
{ code: 'SK', name: 'Slovakia', dial: '+421' },
|
|
|
|
|
{ code: 'SI', name: 'Slovenia', dial: '+386' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'SO', name: 'Somalia', dial: '+252' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'ZA', name: 'South Africa', dial: '+27' },
|
|
|
|
|
{ code: 'KR', name: 'South Korea', dial: '+82' },
|
|
|
|
|
{ code: 'ES', name: 'Spain', dial: '+34' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'LK', name: 'Sri Lanka', dial: '+94' },
|
|
|
|
|
{ code: 'SD', name: 'Sudan', dial: '+249' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'SE', name: 'Sweden', dial: '+46' },
|
|
|
|
|
{ code: 'CH', name: 'Switzerland', dial: '+41' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'SY', name: 'Syria', dial: '+963' },
|
|
|
|
|
{ code: 'TW', name: 'Taiwan', dial: '+886' },
|
|
|
|
|
{ code: 'TJ', name: 'Tajikistan', dial: '+992' },
|
|
|
|
|
{ code: 'TZ', name: 'Tanzania', dial: '+255' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'TH', name: 'Thailand', dial: '+66' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'TT', name: 'Trinidad & Tobago', dial: '+1' },
|
|
|
|
|
{ code: 'TN', name: 'Tunisia', dial: '+216' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'TR', name: 'Turkey', dial: '+90' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'TM', name: 'Turkmenistan', dial: '+993' },
|
|
|
|
|
{ code: 'UG', name: 'Uganda', dial: '+256' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'UA', name: 'Ukraine', dial: '+380' },
|
|
|
|
|
{ code: 'AE', name: 'United Arab Emirates', dial: '+971' },
|
|
|
|
|
{ code: 'GB', name: 'United Kingdom', dial: '+44' },
|
|
|
|
|
{ code: 'US', name: 'United States', dial: '+1' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'UY', name: 'Uruguay', dial: '+598' },
|
|
|
|
|
{ code: 'UZ', name: 'Uzbekistan', dial: '+998' },
|
|
|
|
|
{ code: 'VE', name: 'Venezuela', dial: '+58' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'VN', name: 'Vietnam', dial: '+84' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'YE', name: 'Yemen', dial: '+967' },
|
|
|
|
|
{ code: 'ZM', name: 'Zambia', dial: '+260' },
|
|
|
|
|
{ code: 'ZW', name: 'Zimbabwe', dial: '+263' },
|
2026-05-21 23:41:19 +02:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
function dialFor(code: string): string {
|
|
|
|
|
return COUNTRIES.find((c) => c.code === code)?.dial ?? '+41';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Combine a dial code and a locally-typed number into strict E.164. */
|
|
|
|
|
function toE164(dial: string, local: string): string {
|
|
|
|
|
const digits = local.replace(/\D/g, '').replace(/^0+/, '');
|
|
|
|
|
return dial + digits;
|
|
|
|
|
}
|
|
|
|
|
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
function errCode(err: unknown): string {
|
|
|
|
|
const detail = (err as { detail?: { error?: string } }).detail;
|
|
|
|
|
return detail?.error ?? (err as Error).message ?? 'unknown';
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 00:30:20 +02:00
|
|
|
export default function LoginPage() {
|
2026-05-25 18:51:57 +02:00
|
|
|
const [providers, setProviders] = useState({
|
|
|
|
|
google: false,
|
|
|
|
|
github: false,
|
|
|
|
|
sms: false,
|
|
|
|
|
email: false,
|
|
|
|
|
});
|
|
|
|
|
// Default to SMS — email is off by default until an SMTP/Resend provider
|
|
|
|
|
// is wired. The effect below flips to 'email' if the backend says it's on.
|
|
|
|
|
const [method, setMethod] = useState<'email' | 'phone'>('phone');
|
2026-05-19 00:30:20 +02:00
|
|
|
const [error, setError] = useState<string | null>(null);
|
2026-05-21 00:26:44 +02:00
|
|
|
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
// Email magic-link
|
|
|
|
|
const [email, setEmail] = useState('');
|
|
|
|
|
const [emailState, setEmailState] = useState<'idle' | 'sending' | 'sent'>('idle');
|
2026-05-21 00:26:44 +02:00
|
|
|
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
// SMS one-time code
|
2026-05-21 23:41:19 +02:00
|
|
|
const [country, setCountry] = useState('CH');
|
|
|
|
|
const [phoneLocal, setPhoneLocal] = useState('');
|
|
|
|
|
const [sentTo, setSentTo] = useState('');
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
const [code, setCode] = useState('');
|
|
|
|
|
const [smsStep, setSmsStep] = useState<'phone' | 'code'>('phone');
|
|
|
|
|
const [smsBusy, setSmsBusy] = useState(false);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-05-25 18:51:57 +02:00
|
|
|
apiFetch<{ google: boolean; github: boolean; sms: boolean; email: boolean }>(
|
|
|
|
|
'/v1/auth/providers',
|
|
|
|
|
)
|
|
|
|
|
.then((p) => {
|
|
|
|
|
setProviders(p);
|
|
|
|
|
// Pick the most-likely method up-front: email if enabled, else SMS.
|
|
|
|
|
if (p.email) setMethod('email');
|
|
|
|
|
else if (p.sms) setMethod('phone');
|
|
|
|
|
})
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
.catch(() => undefined);
|
feat: particle cloud (no discrete dots) + geo-IP country preselect on login
Two coordinated polish moves the owner asked for.
## 1. Hero particle field — "no white dots, just a glow that follows the mouse and is always in motion"
Previous tuning (uPointSize 2.8, uBaseAlpha 0.6) gave discrete indigo
dots that additively saturated to near-white in dense clusters. The
owner wanted no granular dots visible at all — a continuous indigo
cloud that the cursor pulls toward itself.
Changes:
- **Render fragment**: replaced the anti-aliased disc SDF
(`smoothstep(0.5, 0.42, d)` — hard edge) with a Gaussian falloff
(`exp(-d * d * 6.0)` — smooth blob, no edge). Each particle is now
a soft volume that blends seamlessly with neighbours.
- **Sim fragment**: replaced the outward-gradient ring push with a
mouse-halo attraction. Particles drift toward an ideal radius
(~0.20) around the cursor, with exp-bell falloff so they don't
collapse onto the cursor or feel influenced from across the canvas.
`ringField()` helper is now unused but kept for future use.
- **JS uniforms**: `uPointSize` 2.8→14 (256-tier) / 3.6→20 (128-tier);
`uBaseAlpha` 0.6→0.055. Individual particles are below the
perception threshold for "dot" but 65k of them additively composite
into a continuous cloud. With the much lower per-particle alpha,
the cumulative brightness never saturates to white.
- **ParticleField tick loop**: asymmetric ring-active fade — `alpha
= 0.14` ramping in (fast cursor response), `0.012` decaying out
(slow glow trail after the pointer moves away). Matches the brief
"glow longer + attractive to mouse but always in motion".
- **ParticleHero index.tsx**: added an always-on indigo radial
gradient behind the WebGL canvas, so the hero never reads as
visually empty between frames — the canvas additively paints the
dynamic cloud on top. Removed the white-dot stipple from the
static fallback (it was the most likely source of the "weisse
punkte" complaint for any visitor on the fallback path).
## 2. SMS login — pre-select country picker from visitor's geo-IP
The country picker on `/login` previously defaulted to `'CH'` for
everyone. Visitors from DE / AT / US / etc. had to manually scroll
to their dial code — small friction but it sits on the highest-stakes
conversion step in the funnel.
- **New API route** `apps/api/src/routes/geo.ts` →
`GET /v1/geo/country` returns `{ country: 'CH' | 'DE' | … | null }`
by reading Cloudflare's `CF-IPCountry` header. Public, no auth —
reading a 2-letter country code from a geo-IP header isn't PII
under GDPR / DSG. `'XX'` and `'T1'` (CF's "unknown" + Tor) are
normalised to `null`. Outside CF (dev), header is missing → null.
- **Login page** picks up the result in the existing `useEffect`,
guards against codes not in our country list, and calls `setCountry`
to override the `'CH'` default. Stays at `'CH'` if the detection
fails or the visitor is on a Tor exit. Verified live: the endpoint
returns `{"country":"DE"}` from CF's German edge.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:17:20 +02:00
|
|
|
|
|
|
|
|
// Pre-select the country picker from the visitor's geo-IP. The
|
|
|
|
|
// backend reads Cloudflare's CF-IPCountry header (never the IP
|
|
|
|
|
// itself) and returns the ISO-3166 alpha-2 code. We only override
|
|
|
|
|
// the default 'CH' if the detected code is one we actually carry
|
|
|
|
|
// a dial code for — otherwise the picker would show "Select country".
|
|
|
|
|
apiFetch<{ country: string | null }>('/v1/geo/country')
|
|
|
|
|
.then((r) => {
|
|
|
|
|
const code = r.country;
|
|
|
|
|
if (!code) return;
|
|
|
|
|
if (COUNTRIES.some((c) => c.code === code)) {
|
|
|
|
|
setCountry(code);
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.catch(() => undefined);
|
|
|
|
|
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
const err = new URLSearchParams(window.location.search).get('error');
|
|
|
|
|
if (err) setError(ERROR_COPY[err] ?? 'Sign-in failed. Please try again.');
|
2026-05-21 00:26:44 +02:00
|
|
|
}, []);
|
2026-05-19 00:30:20 +02:00
|
|
|
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
async function sendMagicLink(e: React.FormEvent) {
|
2026-05-19 00:30:20 +02:00
|
|
|
e.preventDefault();
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
setEmailState('sending');
|
2026-05-19 00:30:20 +02:00
|
|
|
setError(null);
|
|
|
|
|
try {
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
await apiFetch('/v1/auth/magic-link', { method: 'POST', body: JSON.stringify({ email }) });
|
|
|
|
|
setEmailState('sent');
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setEmailState('idle');
|
|
|
|
|
setError(ERROR_COPY[errCode(err)] ?? 'Could not send the link.');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function requestSmsCode(e: React.FormEvent) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setSmsBusy(true);
|
|
|
|
|
setError(null);
|
2026-05-21 23:41:19 +02:00
|
|
|
const full = toE164(dialFor(country), phoneLocal);
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
try {
|
2026-05-21 23:41:19 +02:00
|
|
|
await apiFetch('/v1/auth/sms/request', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify({ phone: full }),
|
|
|
|
|
});
|
|
|
|
|
setSentTo(full);
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
setSmsStep('code');
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError(ERROR_COPY[errCode(err)] ?? 'Could not send the SMS.');
|
|
|
|
|
} finally {
|
|
|
|
|
setSmsBusy(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function verifySmsCode(e: React.FormEvent) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setSmsBusy(true);
|
|
|
|
|
setError(null);
|
|
|
|
|
try {
|
|
|
|
|
await apiFetch('/v1/auth/sms/verify', {
|
2026-05-19 00:30:20 +02:00
|
|
|
method: 'POST',
|
2026-05-21 23:41:19 +02:00
|
|
|
body: JSON.stringify({ phone: sentTo, code }),
|
2026-05-19 00:30:20 +02:00
|
|
|
});
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
window.location.href = '/dashboard';
|
2026-05-19 00:30:20 +02:00
|
|
|
} catch (err) {
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
setError(ERROR_COPY[errCode(err)] ?? 'Could not verify the code.');
|
|
|
|
|
setSmsBusy(false);
|
2026-05-19 00:30:20 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
const hasOAuth = providers.google || providers.github;
|
|
|
|
|
|
2026-05-19 00:30:20 +02:00
|
|
|
return (
|
|
|
|
|
<div className="flex min-h-screen items-center justify-center px-6">
|
|
|
|
|
<div className="w-full max-w-sm">
|
|
|
|
|
<Logo className="mb-10" />
|
|
|
|
|
<h1 className="text-[20px] font-semibold tracking-tight">Sign in to your workspace</h1>
|
|
|
|
|
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
Passwordless — pick whichever is easiest.
|
2026-05-19 00:30:20 +02:00
|
|
|
</p>
|
|
|
|
|
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
{hasOAuth && (
|
|
|
|
|
<div className="mt-7 space-y-2">
|
|
|
|
|
{providers.google && (
|
|
|
|
|
<a
|
|
|
|
|
href={apiUrl('/v1/auth/google')}
|
|
|
|
|
className="flex h-10 w-full items-center justify-center gap-2.5 rounded-md border border-[--color-border] bg-[--color-bg-elevated] text-[13px] font-medium text-[--color-fg] transition-colors duration-200 hover:border-[--color-border-strong]"
|
|
|
|
|
>
|
|
|
|
|
<GoogleIcon />
|
|
|
|
|
Continue with Google
|
|
|
|
|
</a>
|
2026-05-21 00:26:44 +02:00
|
|
|
)}
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
{providers.github && (
|
|
|
|
|
<a
|
|
|
|
|
href={apiUrl('/v1/auth/github')}
|
|
|
|
|
className="flex h-10 w-full items-center justify-center gap-2.5 rounded-md border border-[--color-border] bg-[--color-bg-elevated] text-[13px] font-medium text-[--color-fg] transition-colors duration-200 hover:border-[--color-border-strong]"
|
|
|
|
|
>
|
|
|
|
|
<GitHubIcon />
|
|
|
|
|
Continue with GitHub
|
|
|
|
|
</a>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{hasOAuth && (
|
|
|
|
|
<div className="my-5 flex items-center gap-3">
|
|
|
|
|
<span className="h-px flex-1 bg-[--color-border]" />
|
|
|
|
|
<span className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">
|
|
|
|
|
or
|
|
|
|
|
</span>
|
|
|
|
|
<span className="h-px flex-1 bg-[--color-border]" />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-05-25 18:51:57 +02:00
|
|
|
{/* Tab toggle only shown when BOTH email and SMS are enabled — if just
|
|
|
|
|
one is configured, that method's form renders directly without a
|
|
|
|
|
useless one-tab toggle. */}
|
|
|
|
|
{providers.sms && providers.email && (
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
<div
|
|
|
|
|
className={`flex gap-1 rounded-md border border-[--color-border] p-1 ${hasOAuth ? '' : 'mt-7'}`}
|
|
|
|
|
>
|
|
|
|
|
{(['email', 'phone'] as const).map((m) => (
|
|
|
|
|
<button
|
|
|
|
|
key={m}
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setMethod(m);
|
|
|
|
|
setError(null);
|
|
|
|
|
}}
|
|
|
|
|
className={`h-7 flex-1 rounded text-[12px] font-medium transition-colors ${
|
|
|
|
|
method === m
|
|
|
|
|
? 'bg-[--color-bg-subtle] text-[--color-fg]'
|
|
|
|
|
: 'text-[--color-fg-muted] hover:text-[--color-fg]'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{m === 'email' ? 'Email' : 'Phone'}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-05-25 18:51:57 +02:00
|
|
|
<div className={providers.sms || providers.email ? 'mt-4' : hasOAuth ? '' : 'mt-7'}>
|
|
|
|
|
{method === 'email' && providers.email && emailState !== 'sent' && (
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
<form onSubmit={sendMagicLink} className="space-y-3">
|
2026-05-21 00:26:44 +02:00
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label htmlFor="email">Email</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="email"
|
|
|
|
|
type="email"
|
|
|
|
|
required
|
|
|
|
|
autoComplete="email"
|
|
|
|
|
value={email}
|
|
|
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
|
|
|
placeholder="you@company.com"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
type="submit"
|
|
|
|
|
variant="primary"
|
|
|
|
|
size="lg"
|
|
|
|
|
className="w-full"
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
disabled={emailState === 'sending'}
|
2026-05-21 00:26:44 +02:00
|
|
|
>
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
{emailState === 'sending' ? 'Sending…' : 'Send magic link'}
|
2026-05-21 00:26:44 +02:00
|
|
|
</Button>
|
|
|
|
|
</form>
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
)}
|
|
|
|
|
|
2026-05-25 18:51:57 +02:00
|
|
|
{method === 'email' && providers.email && emailState === 'sent' && (
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
<div className="panel p-4">
|
|
|
|
|
<p className="text-[13px]">
|
|
|
|
|
Magic link sent to <span className="mono">{email}</span>.
|
|
|
|
|
</p>
|
|
|
|
|
<p className="mt-1.5 text-[12px] text-[--color-fg-muted]">
|
|
|
|
|
Open it on this device to finish signing in.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{method === 'phone' && smsStep === 'phone' && (
|
|
|
|
|
<form onSubmit={requestSmsCode} className="space-y-3">
|
|
|
|
|
<div className="space-y-1.5">
|
2026-05-21 23:41:19 +02:00
|
|
|
<Label htmlFor="country">Country</Label>
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
<CountryPicker
|
|
|
|
|
countries={COUNTRIES}
|
2026-05-21 23:41:19 +02:00
|
|
|
value={country}
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
onChange={setCountry}
|
|
|
|
|
/>
|
2026-05-21 23:41:19 +02:00
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label htmlFor="phone" hint={dialFor(country)}>
|
|
|
|
|
Phone number
|
|
|
|
|
</Label>
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
<Input
|
|
|
|
|
id="phone"
|
|
|
|
|
type="tel"
|
|
|
|
|
inputMode="tel"
|
|
|
|
|
required
|
2026-05-21 23:41:19 +02:00
|
|
|
autoComplete="tel-national"
|
|
|
|
|
value={phoneLocal}
|
|
|
|
|
onChange={(e) => setPhoneLocal(e.target.value)}
|
|
|
|
|
placeholder="79 123 45 67"
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
type="submit"
|
|
|
|
|
variant="primary"
|
|
|
|
|
size="lg"
|
|
|
|
|
className="w-full"
|
|
|
|
|
disabled={smsBusy}
|
|
|
|
|
>
|
|
|
|
|
{smsBusy ? 'Sending…' : 'Send code'}
|
|
|
|
|
</Button>
|
|
|
|
|
</form>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{method === 'phone' && smsStep === 'code' && (
|
|
|
|
|
<form onSubmit={verifySmsCode} className="space-y-3">
|
|
|
|
|
<div className="space-y-1.5">
|
2026-05-21 23:41:19 +02:00
|
|
|
<Label htmlFor="code" hint={`sent to ${sentTo}`}>
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
6-digit code
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="code"
|
|
|
|
|
inputMode="numeric"
|
|
|
|
|
autoComplete="one-time-code"
|
|
|
|
|
required
|
|
|
|
|
maxLength={6}
|
|
|
|
|
value={code}
|
|
|
|
|
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
|
|
|
|
|
placeholder="123456"
|
|
|
|
|
className="mono tracking-[0.3em]"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
type="submit"
|
|
|
|
|
variant="primary"
|
|
|
|
|
size="lg"
|
|
|
|
|
className="w-full"
|
|
|
|
|
disabled={smsBusy || code.length !== 6}
|
|
|
|
|
>
|
|
|
|
|
{smsBusy ? 'Verifying…' : 'Verify & sign in'}
|
|
|
|
|
</Button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setSmsStep('phone');
|
|
|
|
|
setCode('');
|
|
|
|
|
setError(null);
|
|
|
|
|
}}
|
|
|
|
|
className="w-full text-[12px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg]"
|
|
|
|
|
>
|
|
|
|
|
← Use a different number
|
|
|
|
|
</button>
|
|
|
|
|
</form>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{error && <p className="mt-3 text-[12px] text-[--color-danger]">{error}</p>}
|
|
|
|
|
</div>
|
2026-05-19 00:30:20 +02:00
|
|
|
|
|
|
|
|
<div className="mt-8 text-[12px] text-[--color-fg-subtle]">
|
|
|
|
|
<Link href="/" className="transition-colors hover:text-[--color-fg]">
|
|
|
|
|
← Back to home
|
|
|
|
|
</Link>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-05-21 00:26:44 +02:00
|
|
|
|
|
|
|
|
function GoogleIcon() {
|
|
|
|
|
return (
|
|
|
|
|
<svg width="16" height="16" viewBox="0 0 18 18" aria-hidden="true">
|
|
|
|
|
<path
|
|
|
|
|
fill="#4285F4"
|
|
|
|
|
d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844a4.14 4.14 0 0 1-1.796 2.716v2.259h2.908c1.702-1.567 2.684-3.875 2.684-6.615Z"
|
|
|
|
|
/>
|
|
|
|
|
<path
|
|
|
|
|
fill="#34A853"
|
|
|
|
|
d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18Z"
|
|
|
|
|
/>
|
|
|
|
|
<path
|
|
|
|
|
fill="#FBBC05"
|
|
|
|
|
d="M3.964 10.706A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.706V4.962H.957A8.997 8.997 0 0 0 0 9c0 1.452.348 2.827.957 4.038l3.007-2.332Z"
|
|
|
|
|
/>
|
|
|
|
|
<path
|
|
|
|
|
fill="#EA4335"
|
|
|
|
|
d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.962L3.964 7.294C4.672 5.167 6.656 3.58 9 3.58Z"
|
|
|
|
|
/>
|
|
|
|
|
</svg>
|
|
|
|
|
);
|
|
|
|
|
}
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
|
|
|
|
|
function GitHubIcon() {
|
|
|
|
|
return (
|
|
|
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
|
|
|
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82a7.6 7.6 0 0 1 2-.27c.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
|
|
|
|
|
</svg>
|
|
|
|
|
);
|
|
|
|
|
}
|