feat: added examples/demos (#3459)

### **PR Type**
Enhancement, Tests


___

### **Description**
- Add multi-framework auth demos and examples
  - React Native, React, Next.js SSR, Vue, SvelteKit, Express
  - Email/password, magic link, MFA, WebAuthn flows
  - File upload, profile, logs viewing

___



<details> <summary><h3> File Walkthrough</h3></summary>

<table><thead><tr><th></th><th align="left">Relevant
files</th></tr></thead><tbody><tr><td><strong>Enhancement</strong></td><td><details><summary>18
files</summary><table>
<tr>
<td><strong>MFASettings.tsx</strong><dd><code>Add React Native MFA
settings screen</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-5d346e2efc6817f6c3de4360c27031e4d5a4f37423aedb329f645aa9ac33d6e8">+594/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>upload.tsx</strong><dd><code>Add React Native file upload
screen</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-3763882523f551153b1520c05a953fdd2b3e7e0c37e2db53318e6a2f05d09b9f">+552/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>Upload.tsx</strong><dd><code>Add React SPA file upload
page</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-aadcba440d58667ce429ae0caad0695a7dddc8d91e1b8f0dc52ce6633b0eddc4">+444/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>SecurityKeys.tsx</strong><dd><code>Add React SPA WebAuthn
security keys</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-3f9cf11913bfd3220c4c220618b5575c6a3ed59ffa42d6d5be32c77edfc7d610">+404/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>client.tsx</strong><dd><code>Add Next.js SSR file upload
client</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-f6fa7dea8d8baf6d7fcefaf4517a492dbfca213afcf6b5d08d180a889947c0e5">+399/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>SecurityKeyClient.tsx</strong><dd><code>Add Next.js SSR
WebAuthn key client</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-c05c21b6e3c47e8d17ba69ebba93e3ccd4266e26fd4062e6de552ed98d46c603">+351/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>signup.tsx</strong><dd><code>Add React Native signup screen
with tabs</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-262b09b9dd7234cad96fe092d7131a23451c9e50b98c126c9e36599b3a127ac6">+387/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>signin.tsx</strong><dd><code>Add React Native signin screen
with MFA</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-3a4105c32f4aa4290e4fc0a12c2cc3121b4b0901c1429917087e26569c1c0a7a">+367/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>MFASettings.tsx</strong><dd><code>Add React SPA MFA settings
component</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-e43f9e6aec21b8620c86b9cdf9986e2e8dace52d87609b1c74cf6ab9a0639d21">+288/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>mfa-settings.tsx</strong><dd><code>Add Next.js SSR MFA
settings client</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-75767c82cc4c7eb779f3beb7ca5c5f9b2cc090347029ab44cc7f93b8b881c85b">+292/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>profile.tsx</strong><dd><code>Add React Native profile
screen</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-e996eb818728427e1c131ac6f3cba0bd506ad8f28486ee47c9ceb5b33dbc7869">+262/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>verify.tsx</strong><dd><code>Add React Native email verify
screen</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-93aeb3cfb31ac602e11e6031bb4860e81385c3c9693e5285c8f69988341cd045">+265/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>index.ts</strong><dd><code>Add Express server Nhost client
example</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-337bf1c26c434751c7c3e356598278e8e05482e41936a30c587cf68fd375092f">+126/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>email-confirm-change.tsx</strong><dd><code>Add email
confirm-change template generator</code>&nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-5ee9a92531c20cb74b9a49335eb0a65cdd73efa7d8c59020ea80163bba2cec72">+129/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>AppleSignIn.tsx</strong><dd><code>Add Expo Apple Sign In
integration</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-8fcf005753999f7770661afbc82063eb0637a2f959f03a77a94d4eef55b1d9cf">+112/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>render-emails.ts</strong><dd><code>Add email templates
render script</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-760314d4066ab3171a8dd9ec712e5e6b4e373f86531e66c502df06dab8f9dc7d">+82/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>auth.ts</strong><dd><code>Add Vue demo Nhost auth
store</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-db6008d5d08993870173045b4d2e978832e91943222dad7f6093537c48277e4f">+75/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>App.tsx</strong><dd><code>Set up React SPA routes and auth
provider</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-849f3aa52970f348de49a27094aac4e4b8cb8cf29580cada70d37f1a04249725">+77/-0</a>&nbsp;
&nbsp; </td>

</tr>

</table></details></td></tr><tr><td><strong>Tests</strong></td><td><details><summary>2
files</summary><table>
<tr>
<td><strong>DateTimePicker.test.tsx</strong><dd><code>Simplify
DateTimePicker test awaits</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-c7076012eb33d6f60049710638b5ad19c2f310b8c250c79f1905be7e0a30b00a">+12/-12</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>useProjectLogs.test.ts</strong><dd><code>Update project logs
tests to use CoreLogService</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-13d900aa08d06962a09628136b893801ad62a96c3ff89d380c5c4b7ae92d891e">+9/-9</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>
</table></details></td></tr><tr><td><strong>Additional
files</strong></td><td><details><summary>101 files</summary><table>
<tr>
  <td><strong>examples_demos_checks.yaml</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-48c5a14a5d1da9f35b409ecc95fae8f3a319f97bffbf0020efcb8c360347dc02">+94/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>biome.json</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-2bc8a1f5e9380d5a187a4e90f11b4dd36c3abad6aea44c84be354a4f44cdec55">+43/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>LogsServiceFilter.tsx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-a590a7298a9f040df9f26c4eb37d10fc36f47c32996f71aec47796f08c44e892">+8/-7</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>DeploymentServcieLogsHeader.test.tsx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-46fb5d0c9528168323c0e16ef4186d91fe6274b64292f43841258bdfc45dd581">+81/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>DeploymentServiceLogs.tsx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-333a9783713e9a4bad1b5327e117cbe69148091abe8b9038d36132b5f4635bbe">+3/-3</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>DeploymentServiceLogsHeader.tsx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-4f102c06ed32bb3d8245e415e76b0b14d2d4ae3abca6e234edf69278325c7a95">+3/-3</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>useProjectLogs.ts</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-10efc67700b3f024dd03442eacd339802e951696d04caa76bd5a864bd5c7c83f">+3/-3</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>LogsBody.tsx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-b628e511a7fb9b237ac691b27ab9585eed0d0803144cde66c3af7fa6f9a2dc40">+2/-2</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>LogsHeader.test.tsx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-6a348a6b3f868aac854020f2b85ff9a7cf5d61f362a5201e77681e4d5a576f20">+86/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>LogsHeader.tsx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-ebb3285aa776c9c5ea8b72672c4aafd55994c6c694998bbf56ca9c56d1e77664">+3/-3</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>services.ts</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-8fcdaed33322718091b613ae22c65cc3eb61972904b5af46866b160c9bbbe48c">+13/-13</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>logs.tsx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-77489a68a7526d74f06d59019ad68c44728b7620637308d70fba38d6649b73fa">+3/-3</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>Makefile</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-80fe306cf2e39e0852a6033f331195be7942ed7fb54b2cac6bd0139cc34d1bb6">+17/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>APPLE_SIGN_IN_SETUP.md</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-eeb0deb7c8dae0e6b49462b92e5e7176ee11236759847ea59253375ba48d6f5f">+95/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>README.md</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-29693cbc361101a671771703a3097f97b0ad6c344435cb3fa6d19b00c030ecac">+169/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>README_MAGIC_LINKS.md</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-9232aee57e45778533da1f08d80d794f612f3672b4e2fc0a77c4ca6e03e27d3a">+410/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>README_NATIVE_AUTHENTICATION.md</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-2bfce492dbe0824f4e47907f7269b57b6f8f092550647afc46cff13903a34750">+381/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>README_PROTECTED_ROUTES.md</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-25f106def41f290d4c471f95f4a25db4c7d411407f7937035f5e3e53ff90aa6f">+357/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>README_SOCIAL_SIGNIN.md</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-10c036291fe61ed79d9ce5c1764f10e44b6cc57232b0b6b981c19f84760f5e8e">+530/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>app.json</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-12f6ad4bacc187ec247d9ee19b2ed6a5b49c4304508fd3ba4321b265e0eec36f">+53/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>_layout.tsx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-9d1054a3d9a27191c4056537dea211e6a19610581557e756db81677e49e7b929">+54/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>MagicLinkForm.tsx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-169e79f10d79f5ff7df3e910f042005f0b139246098ad4a1d2284ed450409b42">+158/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>NativeLoginForm.tsx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-46f2e1cd61d0d53b7fde9a0056f78e07a317e8fe60b6b237baa0a13d6cd4a4e0">+93/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>ProtectedScreen.tsx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-4c71e8ae5499fc1dda82b020fd0b1f2ce2f972d18298af69d6c3c553742d2ad0">+40/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>SocialLoginForm.tsx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-2e46da6c133f57ab9748231ab7985a6704893b6d99c2981007a3cfeaeaee2b79">+91/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>index.tsx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-0db4f13bb27522261f48250563b1f9d6e6cd5805c9b89caf2bce331b229a4cca">+120/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>AsyncStorage.tsx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-7c877423128ee8dadc8bafb89ea8a98b6c1b05130949a6c6d9f3f325a4ccacc1">+93/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>AuthProvider.tsx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-72e3c353a156c42747b082a51cf6ab9e59a8b5009f8cb637d9e75fb9c31a7212">+110/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>utils.ts</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-4091a110ae026c9f130fb654f47381b503f7a04a1cefc3020831ecb3f0d971ad">+44/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>mfa.tsx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-477afc36e3e2e62727b11e6d1f77592c89d120d8dfd676a05756dcff7625825a">+233/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>package.json</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-9d05689c57c194cb000eb7ff276476869ba4ee40a730c23e47016671d7f17521">+57/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>tsconfig.json</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-1d10ade4fc1aa2dce579be648ec4420554d626d814d1971a6ec8d59018eb3bc8">+15/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>.secrets.example</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-c47199e16c186acba1e9f82c541362d1a6ff05066299b04f61f358d636c3f5d0">+16/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>Makefile</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-5c1f8270d9eede9a24fa01068ede09fa13a8fca85eeda73328701b5781500ebe">+7/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>README.md</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-9a3489ab94ccfa9504db04213d3dbb603c609abf1435e5844d911a03210c3515">+29/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>env-up.sh</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-de9763dc0910faec77518ee1eacfdf7a74a257b88e6055a421f37b9ca85d4280">+8/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>package.json</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-c602bad387df10731905ad3a49ee0a1ba256823a7e1a4b8ca262fc2f407fea4b">+13/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>tsconfig.json</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-7635e254c708cde29d62250387c92e13e0d2a7f4180b11ba11b288a7205ed00a">+11/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>config.yaml</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-4446c65612d85e309d41f928643d796ca2d3970f2ac122559a2c3ced6cfd5a77">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-ebd226650ec2ccfdfa181eb52ac57c538d1258efcdd5eefa6521d661014b0beb">+52/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-f4eec235a2ce395d582333d50a2358e5b4b4758a0d6de2d80bb9b983bb5788c3">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-546c1e26f4f5a58d38dab39ffcd1f1e14ecc6867fd1bece4b6a1dd76a310fe3b">+52/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-a62a136fad466ff5def2190737d76bdb54d3ca7c31d5ed85c0c7b9bdb4adc33a">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-c70655567313cbdee9e332a73651c1e91e33f57fe8a168cb064f4206aca66a20">+52/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-6a415c40c7cfaa325f53043b71d709714101b9f629f954095bcc64862abc744b">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-9fd0c953500d416a457aa8dfcfb3659a517ee82a964b860b1b8afeb8d09c098f">+43/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-fd472f081db09177df1548bebdf3141328273c89d3e76b14e01e62bfdfbe2443">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-236393a4f47ed882febf9eb4dd24d5525bc3f20e5e474f5e34ecdff091555965">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-8f0998f6df2005ee1436d74ad2ce2cd1bf91eb053a304160130d24ac64ecfae1">+52/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-401c18113470ff2d38ad5b98eb3fc4fbb3aa1226c2db81b425a75eec28072607">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-3005f541570bcb879d647fbee1161de8b18981e4b07b8eac07a2250835f605a9">+52/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-ed0a3cf5dabcbecde2cb9e914d827ca6a92cd0818aff7b45e51e8b5bdeec27df">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-b7ec4c42b59e6f2e23f1ba196befd43d08632b13747b48c6ceaba3d6d1e38209">+52/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-026c60a66006fe593b8c30cba71ef52d4968f22f004c68bcde8356312de00dce">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-9e40803f1b207ababf5640dec47083efa84dfbc201e968b37730e515081a3101">+52/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-143e6ac6a6fff08a7d6a68f98cae196d569fe4f30fc0215633c5b653ce349181">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-613a3b66d30c6e4b93257765df35c2d0be6379b6f7ab6f3478b4731c1d21ee96">+43/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-6f9c1433fcaabe15051bad4b1d888203d928a30ab2da829c724d54f74ecc818f">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-1727f5ec1686c6079eb733d7f3c5cc60a6f047635241bbf4c63fc5985a78b13e">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-f65964bb501d3baeba4974daaab7c0e6edaec0645860b119633d09424deb995a">+52/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-b911f5720caca8a68bc5ba072284c4d40e1e6b3283a2070c17e84a3d9ba55f79">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-7f1525fdd0e82fd57d181d56acb7828c5838ef635403c560b1dfb88834cc5996">+52/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-afb123c7657dcf08247b52027d73e4d9dcee51d0cb7e37a1de0f31200c4dc535">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-877b5d458de6de8bb1287075e3eb6a6f8aaba807ddd2db78705640512ce038a6">+52/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-01135b2530cf6bf46ae7398f1ed4e1a684cbfff1af5fc7dc28eacbd3b1c66caf">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-efa400ab45cf1d64d014c44c6308be2146eb1657c02f8ac7e47c9f71fb34bd4f">+52/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-a7a3eec9c3ce1f9d763a196f2a6af166c07c3c87f05967261847cb40219814fe">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-344d73792431a98012dcfe76f75525a383827ef6f81ab1688b46ba504c34a903">+43/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-d01fea24e87babd4f89d3ce27578f96a35578511bebad2c0c2f2689160c0fc63">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-dd12ae6e043b5085514f43691a710f3e65a0c3df93c8847ffd9678c8fae36c08">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-fa998d4e425d971dd546898cac4d1183dd4c10d4d95f19d4f4650d322a58505c">+52/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-1c7f1643eece2cc9fdad3cc032d60826d541d6ad9123c9674ea1de20f4ef429f">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-bd2c5decf64f436932af01d32dc08a35fb745c8aacf97952be9f8925fb94eed4">+52/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-f4187b87464106220110abbc03675a30e6e5154248be0880f75e7b976419e3ef">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-9d893b6b685830b2cdce2b05a925482b93c5407a7d7e2f2f76d159e55a089d6a">+52/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-05b560e90f649549a7d98396179732b15c4af26b8df6fa5dc3b594b49eb548d0">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-2bef9e62baac1dabaf929cbb9113ad6c5711eb8ba364defebefd9cb9e65703e8">+52/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-d0ca2cbd1895c7841755f2f8bb109ba17f6018a53f6615ab45445660c947742d">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-2df8e46ef47cbebf23bfe3bba1e963ebc93d1bde4c09d6bc18cafd008810fe11">+43/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-21ce711d17cbc8006a363b11e33f0b9009b3c7c7ba6885e9b6fccb64755ac0cd">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-692abf5d4051aa5afc5342cdd36c24879777744678ea1cf6a1a246f2d4cb60ea">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-1443b315ed3da71a04c3d532a46dd6fc837cbf67a9813d6d9b7dcaad4fe8f617">+52/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-b034b26b4c5e31f76049c742cf80148731a686744a3dd0e440399521ad2491d9">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-52643cd4c649348b055c9659cda85a7570f974c47aaa7af473814cea851fb9a7">+52/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-c1538ae90cc62160dbcb4be240cef50eaf241b88ce4cb391e5851e8b346d73bf">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-5e695a8cb0adf5ff5a530a922bc8f5b52d00972a99951f2bd690e4f44ccefae8">+52/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-532dbd0e9c1f0d9d76715a27d174745824cfc68d0f7e499afa7e7e2c150b8d19">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-07b82a5305d283da9c6781f44a88c0ff4131b575fe5ee78a15d69abf6bd91b87">+52/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-9b9ee0fb48f9f8815980bfe94d7d93d41bb9f1511cad6852a6d4f7e9192faf47">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-d3755355865e28005c894a10824920696b06e64e7c1c8765268908d56aa12261">+43/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-d5c3314a4bfa1667da59c2072178f80a4654ac0f10f714b378496a69b6e21dcd">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-a0fda535693ff92e05311d4a63884ae0063e90eadeba20e958a7f9da51016ec5">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-de84bf86da7ce4481559b7037412128a4928746589b35575b9dd811c9f50b391">+52/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-d17f4b74ce523c6ed011f1d7a3426dcbe430716a50803ee2afdb0f81e5d047fe">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>README.md</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-3266bd27efc68f506fa6c7d9ab41567c63a9fabf3d2d247f5719be551efbf250">+23/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>email-verify.tsx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-80cd5ff8c6c8b4f7a588058618ed27b3316304b1786a92ea8ffbb4c3fda96ac5">+127/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>password-reset.tsx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-cef64f7d4eab756b7c68477e4fcc323cf8197832c92fd07e65f24dc71c9e3e80">+127/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>signin-otp.tsx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-a2a5f9edf38127ed8168a60248e51e6ba1b4026fe7e8366111f28e0502749e6f">+125/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>signin-passwordless.tsx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-38ef73aa255432a72a30ab3e63da415b26598ce7b267a1e39957c26aefac1d88">+127/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-1c287ea5a500ae373cd8c021d07dc0f3994db71077e892d7b5761baf5c2ef037">+8/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>Additional files not shown</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3459/files#diff-2f328e4cd8dbe3ad193e49d92bcf045f47a6b72b1e9487d366f6b8288589b4ca"></a></td>

</tr>
</table></details></td></tr></tr></tbody></table>

</details>

___
This commit is contained in:
David Barroso
2025-09-05 08:50:56 +02:00
parent c6af08fde4
commit 3a41251caf
324 changed files with 73421 additions and 20719 deletions

View File

@@ -0,0 +1,94 @@
---
name: "examples/demos: check and build"
on:
# pull_request_target:
pull_request:
paths:
- '.github/workflows/wf_check.yaml'
- '.github/workflows/examples_demos_checks.yaml'
# common build
- 'flake.nix'
- 'flake.lock'
- 'nixops/**'
- 'build/**'
# common go
- '.golangci.yaml'
- 'go.mod'
- 'go.sum'
- 'vendor/**'
# codegen
- 'tools/codegen/**'
# common javascript
- ".npmrc"
- ".prettierignore"
- ".prettierrc.js"
- "audit-ci.jsonc"
- "package.json"
- "pnpm-workspace.yaml"
- "pnpm-lock.yaml"
- "turbo.json"
# nhpst-js
- 'packages/nhost-js/**'
# demos
- 'examples/demos/**'
push:
branches:
- main
jobs:
check-permissions:
runs-on: ubuntu-latest
steps:
- run: |
echo "github.event_name: ${{ github.event_name }}"
echo "github.event.pull_request.author_association: ${{ github.event.pull_request.author_association }}"
- name: "This task will run and fail if user has no permissions and label safe_to_test isn't present"
if: "github.event_name == 'pull_request_target' && ! ( contains(github.event.pull_request.labels.*.name, 'safe_to_test') || contains(fromJson('[\"OWNER\", \"MEMBER\", \"COLLABORATOR\"]'), github.event.pull_request.author_association) )"
run: |
exit 1
tests:
uses: ./.github/workflows/wf_check.yaml
needs:
- check-permissions
with:
NAME: demos
PATH: examples/demos
GIT_REF: ${{ github.sha }}
secrets:
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
NIX_CACHE_PRIV_KEY: ${{ secrets.NIX_CACHE_PRIV_KEY }}
build_artifacts:
uses: ./.github/workflows/wf_build_artifacts.yaml
needs:
- check-permissions
with:
NAME: demos
PATH: examples/demos
GIT_REF: ${{ github.sha }}
VERSION: 0.0.0-dev # we use a fixed version here to avoid unnecessary rebuilds
DOCKER: false
secrets:
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
NIX_CACHE_PRIV_KEY: ${{ secrets.NIX_CACHE_PRIV_KEY }}
remove_label:
runs-on: ubuntu-latest
needs:
- check-permissions
steps:
- uses: actions-ecosystem/action-remove-labels@v1
with:
labels: |
safe_to_test
if: contains(github.event.pull_request.labels.*.name, 'safe_to_test')

View File

@@ -84,16 +84,16 @@ jobs:
# || (echo "Wait until nixops is already built and cached and run again" && exit 1)
# if: ${{ inputs.NAME != 'nixops' }}
# - name: "Verify if we need to build"
# id: verify-build
# run: |
# export drvPath=$(make check-dry-run)
# echo "Derivation path: $drvPath"
# nix path-info --store s3://nhost-nix-cache\?region=eu-central-1 $drvPath \
# && export BUILD_NEEDED=no \
# || export BUILD_NEEDED=yes
# echo BUILD_NEEDED=$BUILD_NEEDED >> $GITHUB_OUTPUT
# echo DERIVATION_PATH=$drvPath >> $GITHUB_OUTPUT
- name: "Verify if we need to build"
id: verify-build
run: |
export drvPath=$(make check-dry-run)
echo "Derivation path: $drvPath"
nix path-info --store s3://nhost-nix-cache\?region=eu-central-1 $drvPath \
&& export BUILD_NEEDED=no \
|| export BUILD_NEEDED=yes
echo BUILD_NEEDED=$BUILD_NEEDED >> $GITHUB_OUTPUT
echo DERIVATION_PATH=$drvPath >> $GITHUB_OUTPUT
- name: "Start containters for integration tests"
run: |

2
.gitignore vendored
View File

@@ -69,3 +69,5 @@ out/
result
.vitest
.claude

3
.npmrc
View File

@@ -1,2 +1,3 @@
prefer-workspace-packages = true
auto-install-peers = false
auto-install-peers = true
shared-workspace-lockfile = false

43
biome.json Normal file
View File

@@ -0,0 +1,43 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.2/schema.json",
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
"files": { "ignoreUnknown": false },
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 80
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"complexity": {
"useLiteralKeys": "off"
}
},
"includes": ["**", "!.next", "!node_modules"]
},
"javascript": { "formatter": { "quoteStyle": "double" }, "globals": [] },
"assist": {
"enabled": true,
"actions": { "source": { "organizeImports": "on" } }
},
"overrides": [
{
"includes": ["**/*.svelte", "**/*.astro", "**/*.vue"],
"linter": {
"rules": {
"style": {
"useConst": "off",
"useImportType": "off"
},
"correctness": {
"noUnusedVariables": "off",
"noUnusedImports": "off"
}
}
}
}
]
}

View File

@@ -1,3 +0,0 @@
link-workspace-packages = false
auto-install-peers = false
resolution-mode=highest

View File

@@ -130,6 +130,7 @@
},
"devDependencies": {
"@babel/core": "^7.24.3",
"@eslint/js": "9.26.0",
"@faker-js/faker": "^7.6.0",
"@graphql-codegen/cli": "^5.0.2",
"@graphql-codegen/typescript": "^3.0.4",
@@ -157,7 +158,7 @@
"@types/lodash.debounce": "^4.0.9",
"@types/node": "^16.18.93",
"@types/pluralize": "^0.0.30",
"@types/react": "^18.2.73",
"@types/react": "18.2.73",
"@types/react-dom": "^18.2.23",
"@types/react-table": "^7.7.20",
"@types/testing-library__jest-dom": "^5.14.9",
@@ -167,6 +168,7 @@
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-react": "^4.2.1",
"@vitest/coverage-v8": "^0.32.4",
"audit-ci": "^6.6.1",
"autoprefixer": "^10.4.19",
"babel-loader": "^8.3.0",
"babel-plugin-transform-remove-console": "^6.9.4",
@@ -178,10 +180,15 @@
"eslint-config-airbnb-typescript": "^17.1.0",
"eslint-config-next": "^13.5.6",
"eslint-config-prettier": "^8.10.0",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-flowtype": "^8.0.3",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsx-a11y": "^6.8.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^6.2.0",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-vue": "^9.26.0",
"jsdom": "^22.1.0",
"lint-staged": "^15.2.2",
"msw": "^1.3.5",

23599
dashboard/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,44 @@
{ self, pkgs, nix2containerPkgs, nix-filter, nixops-lib, node_modules }:
{ self, pkgs, nix2containerPkgs, nix-filter, nixops-lib }:
let
name = "dashboard";
version = "0.0.0-dev";
created = "1970-01-01T00:00:00Z";
submodule = "${name}";
node_modules = nixops-lib.js.mkNodeModules {
name = "node-modules";
version = "0.0.0-dev";
src = nix-filter.lib.filter {
root = ./..;
include = [
".npmrc"
"package.json"
"pnpm-workspace.yaml"
"pnpm-lock.yaml"
"${submodule}/package.json"
"${submodule}/pnpm-lock.yaml"
];
};
pnpmOpts = "--filter . --filter './${submodule}/**'";
preBuild = ''
mkdir packages
cp -r ${self.packages.${pkgs.system}.nhost-js} packages/nhost-js
'';
};
src = nix-filter.lib.filter {
root = ../.;
include = with nix-filter.lib; [
isDirectory
(matchName "package.json")
".npmrc"
".prettierignore"
".prettierrc.js"
"audit-ci.jsonc"
"package.json"
"pnpm-workspace.yaml"
"pnpm-lock.yaml"
"turbo.json"
@@ -30,6 +55,8 @@ let
"${submodule}/graphql.config.yaml"
"${submodule}/next-env.d.ts"
"${submodule}/next.config.js"
"${submodule}/package.json"
"${submodule}/pnpm-lock.yaml"
"${submodule}/playwright.config.ts"
"${submodule}/postcss.config.js"
"${submodule}/prettier.config.js"
@@ -67,8 +94,8 @@ rec {
inherit src node_modules submodule buildInputs nativeBuildInputs checkDeps;
preCheck = ''
mkdir -p packages/nhost-js
cp -r ${self.packages.${pkgs.system}.nhost-js}/dist packages/nhost-js/dist
rm -rf packages/nhost-js
cp -r ${self.packages.${pkgs.system}.nhost-js} packages/nhost-js
'';
};
@@ -82,6 +109,9 @@ rec {
cp -r ${node_modules}/node_modules/ node_modules
cp -r ${node_modules}/dashboard/node_modules/ dashboard/node_modules
rm -rf packages/nhost-js
cp -r ${self.packages.${pkgs.system}.nhost-js} packages/nhost-js
export HOME=$TMPDIR
cd dashboard
@@ -121,8 +151,8 @@ rec {
cp -r ${node_modules}/node_modules/ node_modules
cp -r ${node_modules}/dashboard/node_modules/ dashboard/node_modules
mkdir -p packages/nhost-js
cp -r ${self.packages.${pkgs.system}.nhost-js}/dist packages/nhost-js/dist
rm -rf packages/nhost-js
cp -r ${self.packages.${pkgs.system}.nhost-js} packages/nhost-js
cd dashboard
pnpm build

View File

@@ -10,6 +10,7 @@
},
"devDependencies": {
"mintlify": "^4.2.87",
"prettier": "^3.5.3",
"typedoc": "^0.28.4",
"typedoc-plugin-markdown": "^4.6.3"
}

7461
docs/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,36 @@
{ self, pkgs, nixops-lib, nix-filter, node_modules }:
{ self, pkgs, nixops-lib, nix-filter }:
let
name = "docs";
version = "0.0.0-dev";
created = "1970-01-01T00:00:00Z";
submodule = "${name}";
node_modules = nixops-lib.js.mkNodeModules {
name = "node-modules";
version = "0.0.0-dev";
src = nix-filter.lib.filter {
root = ../.;
include = [
".npmrc"
"package.json"
"pnpm-workspace.yaml"
"pnpm-lock.yaml"
"${submodule}/package.json"
"${submodule}/pnpm-lock.yaml"
];
};
};
src = nix-filter.lib.filter {
root = ../.;
include = with nix-filter.lib; [
isDirectory
(matchName "package.json")
".npmrc"
".prettierignore"
".prettierrc.js"
"audit-ci.jsonc"
"package.json"
"pnpm-workspace.yaml"
"pnpm-lock.yaml"
"turbo.json"
@@ -47,5 +64,11 @@ in
check = nixops-lib.js.check {
inherit src node_modules submodule buildInputs nativeBuildInputs checkDeps;
preCheck = ''
mkdir -p packages/nhost-js
cp -r ${self.packages.${pkgs.system}.nhost-js}/dist packages/nhost-js/dist
cp -r ${self.packages.${pkgs.system}.nhost-js}/node_modules packages/nhost-js/node_modules
'';
};
}

17
examples/demos/Makefile Normal file
View File

@@ -0,0 +1,17 @@
ROOT_DIR?=$(abspath ../..)
include $(ROOT_DIR)/build/makefiles/general.makefile
.PHONY: _dev-env-up
_dev-env-up:
@echo "Nothing to do"
.PHONY: _dev-env-down
_dev-env-down:
@echo "Nothing to do"
.PHONY: _dev-env-build
_dev-env-build:
@echo "Nothing to do"

View File

@@ -0,0 +1,39 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
app-example

View File

@@ -0,0 +1,95 @@
# Setting Up Apple Sign In for Nhost Authentication
This guide will walk you through the steps to configure Apple Sign In for your React Native application with Nhost authentication.
## Prerequisites
- An Apple Developer account
- Xcode 11 or later
- Access to the Apple Developer portal
## 1. Configure Your App in the Apple Developer Portal
1. Log in to the [Apple Developer portal](https://developer.apple.com/)
2. Go to "Certificates, Identifiers & Profiles"
3. Select "Identifiers" and create a new App ID if you haven't already
4. Enable "Sign In with Apple" capability for your App ID
5. Save your changes
## 2. Create a Service ID for Sign In with Apple
1. In the Apple Developer portal, go to "Certificates, Identifiers & Profiles"
2. Select "Identifiers" and click the "+" button to add a new identifier
3. Choose "Services IDs" and click "Continue"
4. Enter a description and identifier (e.g., "com.nhost.reactnativewebdemo.service")
5. Check "Sign In with Apple" and click "Configure"
6. Add your domain to the "Domains and Subdomains" field
7. Add your return URL in the "Return URLs" field. This should match your Nhost redirect URL
8. Save and register the service ID
## 3. Configure Nhost for Apple Sign In
1. In your Nhost dashboard, go to Authentication > Providers > Apple
2. Enable the provider
3. Enter the following details:
- Team ID: Found in your Apple Developer account
- Service ID: The identifier you created in step 2
- Key ID: Create a new key with "Sign In with Apple" enabled in the Apple Developer portal
- Private Key: The downloaded key file content
## 4. Configure Your Expo/React Native App
1. Make sure the `expo-apple-authentication` package is installed
```
npx expo install expo-apple-authentication
```
2. Ensure your app.json has the proper configuration:
```json
{
"expo": {
"ios": {
"bundleIdentifier": "com.nhost.reactnativewebdemo",
"infoPlist": {
"NSFaceIDUsageDescription": "This app uses Face ID for signing in"
}
},
"plugins": ["expo-apple-authentication"]
}
}
```
3. If you're using EAS Build, make sure you've configured your Apple Developer Team ID:
```
eas credentials
```
## 5. Testing Apple Sign In
When you build your app for iOS:
1. Use a real device or simulator running iOS 13 or later
2. Make sure you're signed into an Apple ID on the device
3. Use the Apple Sign In button and authenticate
4. The app will receive an ID token that is sent to Nhost
5. Nhost will verify the token and create or authenticate the user
## Troubleshooting
- **Invalid Client ID**: Ensure your Service ID is properly configured in the Apple Developer portal
- **Authentication Failed**: Check that your Nhost Apple provider configuration is correct
- **App Build Issues**: Ensure the `expo-apple-authentication` package is properly installed and your app.json is configured correctly
## Security Considerations
- Never store Apple private keys in your front-end code
- The authentication process should always validate tokens on the server side (which Nhost handles)
- Keep your Apple Developer account secure
## Additional Resources
- [Apple Sign In Documentation](https://developer.apple.com/sign-in-with-apple/)
- [Nhost Authentication Documentation](https://docs.nhost.io/authentication)
- [Expo Apple Authentication Documentation](https://docs.expo.dev/versions/latest/sdk/apple-authentication/)

View File

@@ -0,0 +1,169 @@
# Nhost SDK Demo - React Native
This is a comprehensive React Native demo showcasing the Nhost SDK integration with Expo. The application demonstrates various authentication methods, user management, file operations, and GraphQL interactions in a modern React Native environment.
## Features
- **Email/Password Authentication** - Traditional sign-up and sign-in with email
- **Multi-Factor Authentication (MFA)** - TOTP-based 2FA security
- **Magic Link Authentication** - Passwordless authentication via email
- **Social Authentication** - GitHub OAuth integration
- **Native Authentication** - Apple Sign-In for iOS devices
- **User Profile Management** - Display and manage user information
- **Protected Routes** - Route-based authentication guards
- **Session Persistence** - Reliable session storage with AsyncStorage
- **File Operations** - Upload and download functionality
- **GraphQL Operations** - Database queries and mutations
## Quick Start
1. **Install dependencies**
```bash
pnpm install
```
2. **Configure Nhost**
Update `app.json` with your Nhost configuration:
```json
"extra": {
"NHOST_SUBDOMAIN": "your-subdomain",
"NHOST_REGION": "your-region"
}
```
For local development with Nhost CLI:
```json
"extra": {
"NHOST_SUBDOMAIN": "192-168-1-103",
"NHOST_REGION": "local"
}
```
_(Replace with your actual local IP address using hyphens instead of dots)_
3. **Start the development server**
```bash
pnpm start
```
4. **Open the app**
- Scan QR code with Expo Go
- Press `i` for iOS Simulator
- Press `a` for Android Emulator
## Project Structure
The project uses [Expo Router](https://docs.expo.dev/router/introduction/) for file-based navigation:
```
ReactNativeWebDemo/
├── app/
│ ├── _layout.tsx # Root layout with AuthProvider
│ ├── index.tsx # Home/landing screen
│ ├── signin.tsx # Authentication hub with tabs
│ ├── signup.tsx # User registration
│ ├── profile.tsx # Protected user profile
│ ├── upload.tsx # File upload demo
│ ├── verify.tsx # Magic link/social auth verification
│ │
│ ├── components/
│ │ ├── ProtectedScreen.tsx # Route protection wrapper
│ │ ├── MagicLinkForm.tsx # Magic link authentication
│ │ ├── SocialLoginForm.tsx # GitHub OAuth
│ │ ├── NativeLoginForm.tsx # Native auth container
│ │ ├── AppleSignIn.tsx # Apple Sign-In (iOS)
│ │ └── MFASettings.tsx # Multi-factor authentication
│ │
│ └── lib/
│ ├── nhost/
│ │ ├── AuthProvider.tsx # Authentication context
│ │ └── AsyncStorage.tsx # Session persistence adapter
│ └── utils.ts # Utility functions
├── assets/ # App icons and images
├── app.json # Expo configuration
└── README files # Documentation (this file and others)
```
## Architecture Overview
### Authentication Flow
1. **AuthProvider** wraps the entire app providing global auth state
2. **ProtectedScreen** component guards routes requiring authentication
3. **Session persistence** maintains login state across app restarts
4. **Deep linking** handles magic links and OAuth redirects
### Key Components
- **AuthProvider**: Central authentication state management
- **ProtectedScreen**: Higher-order component for route protection
- **Verification flows**: Unified handling for magic links and OAuth callbacks
- **Storage adapter**: Custom AsyncStorage implementation for session persistence
### Supported Authentication Methods
1. **Email/Password**: Traditional username/password with MFA support
2. **Magic Links**: Passwordless authentication via email verification
3. **Social OAuth**: GitHub integration with redirect handling
4. **Native Authentication**: Apple Sign-In using secure enclave
## Configuration
### Environment Variables
Set these values in `app.json` under the `extra` section:
| Variable | Description | Example |
| ----------------- | ---------------------------- | ------------- |
| `NHOST_SUBDOMAIN` | Your Nhost project subdomain | `"myproject"` |
| `NHOST_REGION` | Nhost region | `"us-east-1"` |
### Deep Linking Setup
The app is configured with the scheme `reactnativewebdemo://` for standalone builds and uses Expo's linking system for development.
## Development
### Local Nhost Backend
To run against a local Nhost backend:
1. Start Nhost CLI:
```bash
nhost dev
```
2. Update `app.json`:
```json
"extra": {
"NHOST_REGION": "local",
"NHOST_SUBDOMAIN": "local"
}
```
### Testing Authentication
- Use the sign-in screen's tabbed interface to test different auth methods
- Magic links work in development through proper deep link configuration
- Social authentication requires OAuth app setup in your Nhost dashboard
## Documentation
- [Protected Routes & Email Auth](./README_PROTECTED_ROUTES.md)
- [Native Authentication](./README_NATIVE_AUTHENTICATION.md)
- [Magic Links](./README_MAGIC_LINKS.md)
- [Social Sign-In](./README_SOCIAL_SIGNIN.md)
## Learn More
- [Nhost Documentation](https://docs.nhost.io/)
- [Expo Router Documentation](https://docs.expo.dev/router/)
- [React Native Documentation](https://reactnative.dev/)
- [Expo Documentation](https://docs.expo.dev/)

View File

@@ -0,0 +1,410 @@
# Magic Links Authentication
This document explains how magic links (passwordless authentication) are implemented in the Nhost React Native demo, including deep linking configuration, verification endpoints, and testing strategies.
## Overview
Magic links provide a passwordless authentication method where users receive an email containing a link that automatically authenticates them when clicked. This implementation handles both Expo Go development and standalone app scenarios.
## How Magic Links Work
### Authentication Flow
1. **Email Collection**: User enters their email address
2. **Link Generation**: App requests magic link from Nhost with appropriate redirect URL
3. **Email Delivery**: Nhost sends email with authentication link
4. **Link Click**: User clicks link, which opens the app via deep linking
5. **Token Extraction**: App extracts refresh token from the URL parameters
6. **Authentication**: App uses refresh token to authenticate with Nhost
7. **Redirect**: User is redirected to their profile upon successful authentication
## Implementation Details
### MagicLinkForm Component
```typescript
// app/components/MagicLinkForm.tsx
export default function MagicLinkForm() {
const [email, setEmail] = useState<string>("");
const [isLoading, setIsLoading] = useState<boolean>(false);
const [success, setSuccess] = useState<boolean>(false);
const { nhost } = useAuth();
const handleSubmit = async () => {
setIsLoading(true);
setError(null);
try {
// Create the correct redirect URL for current environment
const redirectUrl = Linking.createURL("verify");
await nhost.auth.signInPasswordlessEmail({
email,
options: {
redirectTo: redirectUrl,
},
});
setSuccess(true);
} catch (err) {
const error = err as FetchError<ErrorResponse>;
setError(
`An error occurred while sending the magic link: ${error.message}`,
);
} finally {
setIsLoading(false);
}
};
// ... UI implementation
}
```
### Key Features
1. **Environment Detection**: Automatically generates correct redirect URLs for Expo Go vs standalone
2. **Error Handling**: Comprehensive error handling with user-friendly messages
3. **Loading States**: Visual feedback during magic link generation
4. **Success Feedback**: Confirmation when magic link is sent
## Deep Linking Configuration
### App Configuration
The app supports deep linking through custom URL schemes:
```json
// app.json
{
"expo": {
"scheme": "reactnativewebdemo",
"ios": {
"infoPlist": {
"CFBundleURLTypes": [
{
"CFBundleURLSchemes": ["reactnativewebdemo"]
}
]
}
}
}
}
```
### URL Format Differences
#### Standalone App
```
reactnativewebdemo://verify?refreshToken=abc123...
```
#### Expo Go Development
```
exp://192.168.1.103:19000/--/verify?refreshToken=abc123...
```
### Dynamic URL Generation
```typescript
// Automatically creates correct URL format for current environment
const redirectUrl = Linking.createURL("verify");
// In Expo Go: exp://192.168.1.103:19000/--/verify
// In standalone: reactnativewebdemo://verify
```
## Verification Endpoint
### Verify Screen Implementation
```typescript
// app/verify.tsx
export default function Verify() {
const params = useLocalSearchParams<{ refreshToken: string }>();
const [status, setStatus] = useState<"verifying" | "success" | "error">(
"verifying",
);
const { nhost, isAuthenticated } = useAuth();
useEffect(() => {
const refreshToken = params.refreshToken;
if (!refreshToken) {
setStatus("error");
setError("No refresh token found in the link");
return;
}
async function processToken(): Promise<void> {
try {
// Brief delay to show verifying state
await new Promise((resolve) => setTimeout(resolve, 500));
// Authenticate using the refresh token
await nhost.auth.refreshToken({ refreshToken });
setStatus("success");
// Redirect to profile after brief success message
setTimeout(() => {
router.replace("/profile");
}, 1500);
} catch (err) {
setStatus("error");
setError(`Authentication failed: ${err.message}`);
}
}
processToken();
}, [params, nhost.auth]);
// ... UI implementation for different states
}
```
### Verification States
1. **Verifying**: Shows loading spinner while processing token
2. **Success**: Displays success message before redirect
3. **Error**: Shows error details and debugging information
## URL Parameter Handling
### Token Extraction
The verify screen extracts authentication parameters from the URL:
```typescript
// Extract refresh token from URL parameters
const params = useLocalSearchParams<{ refreshToken: string }>();
const refreshToken = params.refreshToken;
// Validate token presence
if (!refreshToken) {
setStatus("error");
setError("No refresh token found in the link");
return;
}
```
### Debug Information
For development, the verify screen can display all received parameters:
```typescript
// Debug: Show all URL parameters (development only)
if (__DEV__) {
const allParams: Record<string, string> = {};
Object.entries(params).forEach(([key, value]) => {
if (typeof value === "string") {
allParams[key] = value;
}
});
console.log("Received URL parameters:", allParams);
}
```
## Testing Magic Links
### Development with Expo Go
1. **Start Development Server**:
```bash
npx expo start
```
2. **Note Your Local URL**:
- Check terminal output for development URL (e.g., `exp://192.168.1.103:19000`)
3. **Send Magic Link**:
- Use Magic Link form in the app
- Enter your email address
- Submit the form
4. **Check Email Format**:
- Magic link should use format: `exp://192.168.1.103:19000/--/verify?refreshToken=...`
- The `--` segment is crucial for Expo Go routing
5. **Test the Link**:
- Open email on device with Expo Go installed
- Tap the magic link
- Should open directly in Expo Go
### Testing Strategies
#### Manual Testing
```typescript
// Test different scenarios
const testScenarios = [
"Valid magic link with correct token",
"Expired magic link",
"Invalid refresh token",
"Malformed URL parameters",
"Network connectivity issues",
"Already authenticated user",
];
```
#### Automated URL Testing
```typescript
// Manually test URL handling
const testUrls = [
"exp://192.168.1.103:19000/--/verify?refreshToken=valid_token",
"exp://192.168.1.103:19000/--/verify?refreshToken=invalid_token",
"exp://192.168.1.103:19000/--/verify", // Missing token
];
```
## Environment-Specific Considerations
### Expo Go Limitations
1. **URL Format**: Must use `exp://` protocol with development server URL
2. **Port Changes**: URL changes if development server restarts on different port
3. **Network Dependency**: Requires same network for device and development machine
4. **Debug Access**: Can inspect URL parameters more easily
### Standalone App Benefits
1. **Custom Scheme**: Uses app's custom URL scheme (`reactnativewebdemo://`)
2. **Universal Links**: Can configure universal links for production
3. **App Store Distribution**: Works with published apps
4. **Offline Capability**: Less dependent on development server
## Security Considerations
### Token Security
1. **Short-Lived Tokens**: Refresh tokens have limited lifespan
2. **Single Use**: Tokens are invalidated after successful authentication
3. **Secure Transport**: Links are sent via secure email delivery
4. **Validation**: Server-side token validation prevents tampering
### Best Practices
```typescript
// Implement proper error handling
const processToken = async (token: string) => {
try {
// Validate token format before sending to server
if (!token || token.length < 10) {
throw new Error("Invalid token format");
}
// Use the token
await nhost.auth.refreshToken({ refreshToken: token });
} catch (error) {
// Log error for debugging but don't expose details to user
console.error("Magic link authentication failed:", error);
throw new Error("Authentication failed. Please try again.");
}
};
```
## Troubleshooting
### Common Issues
| Issue | Symptom | Solution |
| ---------------------------------- | --------------------------------------- | ------------------------------------------------------------- |
| Link doesn't open app | Clicking link opens browser instead | Check URL scheme configuration and Expo Go installation |
| "No refresh token" error | Link opens app but shows error | Verify email contains correct URL format with parameters |
| Network errors during verification | Authentication fails with network error | Check Nhost configuration and internet connectivity |
| Wrong URL format in email | Link uses incorrect protocol or format | Verify `Linking.createURL()` usage and development server URL |
### Debug Steps
1. **Check Console Logs**:
```typescript
console.log("Generated redirect URL:", redirectUrl);
console.log("Received URL parameters:", params);
```
2. **Verify Email Content**:
- Check that email contains correct URL format
- Ensure refresh token parameter is present
3. **Test URL Manually**:
- Copy magic link from email
- Paste into browser or use device's URL handler
4. **Network Debugging**:
- Ensure device and development machine are on same network
- Check firewall settings
### Expo Go Specific Debugging
```typescript
// Debug Expo Go URLs
if (__DEV__) {
const expoUrl = Linking.createURL("verify");
console.log("Expo Go URL format:", expoUrl);
// Should output something like:
// exp://192.168.1.103:19000/--/verify
}
```
## Production Deployment
### Universal Links (iOS)
For production iOS apps, configure universal links:
```json
// apple-app-site-association
{
"applinks": {
"apps": [],
"details": [
{
"appID": "TEAMID.com.nhost.reactnativewebdemo",
"paths": ["/verify*"]
}
]
}
}
```
### App Links (Android)
Configure Android app links for seamless user experience:
```xml
<!-- android/app/src/main/AndroidManifest.xml -->
<activity android:name=".MainActivity">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https"
android:host="yourapp.com"
android:pathPrefix="/verify" />
</intent-filter>
</activity>
```
## Related Documentation
- [Protected Routes & Email Auth](./README_PROTECTED_ROUTES.md)
- [Native Authentication](./README_NATIVE_AUTHENTICATION.md)
- [Social Sign-In](./README_SOCIAL_SIGNIN.md)
## External Resources
- [Expo Linking Documentation](https://docs.expo.dev/guides/linking/)
- [React Navigation Deep Linking](https://reactnavigation.org/docs/deep-linking/)
- [Nhost Passwordless Authentication](https://docs.nhost.io/authentication/passwordless)
- [Universal Links (iOS)](https://developer.apple.com/ios/universal-links/)
- [Android App Links](https://developer.android.com/training/app-links)

View File

@@ -0,0 +1,381 @@
# Native Authentication - Apple Sign-In
This document explains how native authentication with Apple Sign-In is implemented in the Nhost React Native demo, including deep linking, nonce generation, ID tokens, and security considerations.
## Overview
Apple Sign-In provides a secure, privacy-focused authentication method for iOS users. The implementation uses cryptographic nonces, identity tokens, and deep linking to ensure a secure authentication flow between the app, Apple's servers, and Nhost.
## Architecture
### Authentication Flow
1. **Nonce Generation**: Create a cryptographic nonce for request verification
2. **Apple Authentication**: Request user authentication from Apple
3. **Identity Token**: Receive signed JWT from Apple containing user information
4. **Nhost Verification**: Send identity token and nonce to Nhost for verification
5. **Session Creation**: Nhost validates the token and creates a user session
## Implementation Details
### Apple Sign-In Component
```typescript
// app/components/AppleSignIn.tsx
const AppleSignIn: React.FC<AppleSignInProps> = ({ setIsLoading }) => {
const { nhost } = useAuth();
const [appleAuthAvailable, setAppleAuthAvailable] = useState(false);
// Check Apple authentication availability
useEffect(() => {
const checkAvailability = async () => {
if (Platform.OS === "ios") {
const isAvailable = await AppleAuthentication.isAvailableAsync();
setAppleAuthAvailable(isAvailable);
}
};
checkAvailability();
}, []);
const handleAppleSignIn = async () => {
try {
// Generate cryptographic nonce
const nonce = Math.random().toString(36).substring(2, 15);
const hashedNonce = await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
nonce,
);
// Request Apple authentication
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
nonce: hashedNonce,
});
// Authenticate with Nhost
if (credential.identityToken) {
const response = await nhost.auth.signInIdToken({
provider: "apple",
idToken: credential.identityToken,
nonce, // Original unhashed nonce
});
if (response.body?.session) {
router.replace("/profile");
}
}
} catch (error) {
// Handle authentication errors
}
};
};
```
## Security Mechanisms
### Cryptographic Nonce
The nonce prevents replay attacks and ensures request authenticity:
```typescript
// Generate random nonce
const nonce = Math.random().toString(36).substring(2, 15);
// Hash nonce for Apple (SHA256)
const hashedNonce = await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
nonce,
);
// Send hashed nonce to Apple
const credential = await AppleAuthentication.signInAsync({
nonce: hashedNonce,
// ...
});
// Send original nonce to Nhost for verification
await nhost.auth.signInIdToken({
provider: "apple",
idToken: credential.identityToken,
nonce, // Original unhashed nonce
});
```
### Why Nonce is Important
1. **Replay Attack Prevention**: Ensures each authentication request is unique
2. **Request Binding**: Links the Apple response to the specific app request
3. **Tampering Detection**: Detects if the response has been modified
4. **Time-bound Security**: Nonces typically have short lifespans
### Identity Token Structure
Apple returns a JWT (JSON Web Token) containing:
```json
{
"iss": "https://appleid.apple.com",
"aud": "com.nhost.reactnativewebdemo",
"exp": 1634567890,
"iat": 1634564290,
"sub": "000123.abc456def789...",
"nonce": "hashed_nonce_value",
"email": "user@example.com",
"email_verified": "true",
"real_user_indicator": "true"
}
```
## Platform Requirements
### iOS Configuration
The app must be properly configured for Apple Sign-In:
```json
// app.json
{
"expo": {
"ios": {
"bundleIdentifier": "com.nhost.reactnativewebdemo",
"infoPlist": {
"NSFaceIDUsageDescription": "This app uses Face ID for signing in"
}
},
"plugins": ["expo-apple-authentication"]
}
}
```
### Availability Check
Apple Sign-In is only available on iOS 13+ devices:
```typescript
const checkAvailability = async () => {
if (Platform.OS === "ios") {
const isAvailable = await AppleAuthentication.isAvailableAsync();
setAppleAuthAvailable(isAvailable);
}
};
```
## Nhost Configuration
### Apple Provider Setup
Configure Apple as an authentication provider in your Nhost dashboard:
1. **Team ID**: Your Apple Developer Team ID
2. **Service ID**: Apple Services ID for your app
3. **Key ID**: Apple Sign-In key identifier
4. **Private Key**: Apple Sign-In private key (P8 file content)
### Server-Side Verification
Nhost performs server-side verification of the identity token:
1. **Signature Verification**: Validates JWT signature using Apple's public keys
2. **Nonce Verification**: Compares hashed nonce in token with provided nonce
3. **Audience Verification**: Ensures token is intended for your app
4. **Expiration Check**: Validates token hasn't expired
5. **Issuer Validation**: Confirms token comes from Apple
## Deep Linking Integration
### URL Scheme Configuration
The app is configured with custom URL schemes for deep linking:
```json
// app.json
{
"expo": {
"scheme": "reactnativewebdemo",
"ios": {
"infoPlist": {
"CFBundleURLTypes": [
{
"CFBundleURLSchemes": ["reactnativewebdemo"]
}
]
}
}
}
}
```
### Handling Deep Links
While Apple Sign-In typically doesn't require custom deep linking (it's handled within the app), the configuration supports it for other authentication flows:
```typescript
// app/verify.tsx - Used by other auth methods
useEffect(() => {
const subscription = Linking.addEventListener("url", handleDeepLink);
return () => subscription?.remove();
}, []);
const handleDeepLink = (event: { url: string }) => {
// Handle incoming deep links from authentication providers
};
```
## Error Handling
### Common Apple Sign-In Errors
```typescript
const handleAppleSignIn = async () => {
try {
// ... authentication logic
} catch (error: any) {
if (error.code === "ERR_CANCELED") {
// User canceled authentication
return;
}
if (error.code === "ERR_INVALID_RESPONSE") {
Alert.alert("Error", "Invalid response from Apple");
return;
}
if (error.code === "ERR_NOT_AVAILABLE") {
Alert.alert("Error", "Apple Sign-In not available on this device");
return;
}
// Generic error handling
Alert.alert("Authentication Error", error.message || "Unknown error");
}
};
```
### Nhost Integration Errors
```typescript
const response = await nhost.auth.signInIdToken({
provider: "apple",
idToken: credential.identityToken,
nonce,
});
if (response.error) {
switch (response.error.message) {
case "Invalid identity token":
Alert.alert("Error", "Authentication failed. Please try again.");
break;
case "Invalid nonce":
Alert.alert("Error", "Security verification failed");
break;
default:
Alert.alert("Error", "Authentication error occurred");
}
}
```
## Privacy Features
### Apple's Privacy Protection
Apple Sign-In provides enhanced privacy features:
1. **Email Relay**: Apple can provide relay emails to protect user's real email
2. **Minimal Data**: Only requests necessary user information
3. **User Control**: Users can choose what information to share
4. **Private Email**: Option to hide real email address
### Handling Private Emails
```typescript
// Handle Apple's private relay emails
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.EMAIL,
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
],
nonce: hashedNonce,
});
// Email might be a private relay address
console.log("Email:", credential.email); // Could be privaterelay@example.com
```
## Testing
### Development Testing
1. **iOS Simulator**: Apple Sign-In works in iOS Simulator (iOS 14+)
2. **Physical Device**: Test on real iOS devices for complete functionality
3. **Xcode Console**: Monitor authentication flow through Xcode logs
### Test Scenarios
```typescript
// Test different authentication states
const testScenarios = [
"First-time sign in with Apple ID",
"Returning user authentication",
"User cancels authentication",
"Network connection issues",
"Invalid Apple ID credentials",
"Apple ID with 2FA enabled",
];
```
## Security Best Practices
### Implementation Guidelines
1. **Always Use Nonce**: Never skip nonce generation for production apps
2. **Validate Server-Side**: Let Nhost handle token validation
3. **Handle Errors Gracefully**: Provide clear feedback to users
4. **Secure Storage**: Let Nhost handle session storage securely
5. **Regular Updates**: Keep Apple authentication libraries updated
### Production Considerations
1. **Apple Developer Account**: Requires paid Apple Developer membership
2. **App Store Review**: Apple Sign-In must be implemented if other social logins exist
3. **Bundle ID Matching**: Ensure bundle ID matches Apple configuration
4. **Certificate Management**: Keep Apple certificates and keys updated
## Troubleshooting
### Common Issues
| Issue | Cause | Solution |
| ---------------------- | ---------------------------------------- | ------------------------------------------------- |
| "Not Available" Error | iOS version < 13 or not configured | Check device compatibility and configuration |
| Invalid Identity Token | Incorrect Nhost Apple configuration | Verify Apple provider settings in Nhost dashboard |
| Nonce Mismatch | Sending hashed nonce to Nhost | Send original unhashed nonce to Nhost |
| Bundle ID Mismatch | App bundle ID doesn't match Apple config | Ensure bundle IDs match in all configurations |
### Debug Tools
```typescript
// Enable debug logging
if (__DEV__) {
console.log("Apple Auth Available:", appleAuthAvailable);
console.log("Generated Nonce:", nonce);
console.log("Hashed Nonce:", hashedNonce);
console.log("Identity Token:", credential.identityToken);
}
```
## Related Documentation
- [Apple Sign-In Setup Guide](./APPLE_SIGN_IN_SETUP.md)
- [Protected Routes & Email Auth](./README_PROTECTED_ROUTES.md)
- [Magic Links](./README_MAGIC_LINKS.md)
- [Social Sign-In](./README_SOCIAL_SIGNIN.md)
## External Resources
- [Apple Sign-In Documentation](https://developer.apple.com/sign-in-with-apple/)
- [Expo Apple Authentication](https://docs.expo.dev/versions/latest/sdk/apple-authentication/)
- [Nhost Apple Provider Setup](https://docs.nhost.io/authentication/providers/apple)
- [JWT Token Inspector](https://jwt.io/) - For debugging identity tokens

View File

@@ -0,0 +1,357 @@
# Protected Routes & Email Authentication
This document explains how protected routes and email/password authentication are implemented in the Nhost React Native demo, including multi-factor authentication (MFA) support.
## Overview
The app implements a robust authentication system with:
- Email/password sign-up and sign-in
- Route protection for authenticated users
- Multi-factor authentication (MFA) with TOTP
- Persistent session management
- Automatic redirects for unauthenticated users
## Authentication Context
### AuthProvider Implementation
The `AuthProvider` component wraps the entire app and provides global authentication state:
```typescript
// app/lib/nhost/AuthProvider.tsx
const AuthProvider = ({ children }: AuthProviderProps) => {
const [user, setUser] = useState<Session["user"] | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const nhost = useMemo(() => {
const subdomain =
Constants.expoConfig?.extra?.["NHOST_SUBDOMAIN"] || "local";
const region = Constants.expoConfig?.extra?.["NHOST_REGION"] || "local";
return createClient({
subdomain,
region,
storage: new NhostAsyncStorage(), // Custom AsyncStorage adapter
});
}, []);
// Session initialization and change listeners...
};
```
### Key Features
1. **Session Persistence**: Uses a custom AsyncStorage adapter that works with Nhost's synchronous interface
2. **Automatic State Updates**: Listens for session changes and updates the global state
3. **Loading States**: Manages loading states during authentication operations
4. **Error Handling**: Graceful handling of storage and authentication errors
## Protected Routes
### ProtectedScreen Component
The `ProtectedScreen` component acts as a higher-order component that protects routes:
```typescript
// app/components/ProtectedScreen.tsx
export default function ProtectedScreen({
children,
redirectTo = "/signin",
}: ProtectedScreenProps) {
const { isAuthenticated, isLoading } = useAuth();
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.replace(redirectTo);
}
}, [isAuthenticated, isLoading, redirectTo]);
if (isLoading) {
return <LoadingSpinner />;
}
if (!isAuthenticated) {
return null; // Will redirect in useEffect
}
return <>{children}</>;
}
```
### Usage Example
Protect any screen by wrapping it with `ProtectedScreen`:
```typescript
// app/profile.tsx
export default function Profile() {
return (
<ProtectedScreen>
<ProfileContent />
</ProtectedScreen>
);
}
```
### Features
1. **Automatic Redirects**: Unauthenticated users are redirected to sign-in
2. **Loading States**: Shows loading indicator while checking authentication
3. **Customizable Redirect**: Can specify where to redirect unauthenticated users
4. **No Flash**: Prevents showing protected content before redirect
## Email/Password Authentication
### Sign Up Flow
```typescript
// User registration with email and password
const handleSignUp = async () => {
const { error } = await nhost.auth.signUp({
email,
password,
options: {
displayName,
},
});
if (!error) {
// User created successfully
router.replace("/profile");
}
};
```
### Sign In Flow
```typescript
// User authentication with email and password
const handleSignIn = async () => {
const { error, needsEmailVerification, needsMfaOtp } =
await nhost.auth.signInEmailPassword({
email,
password,
});
if (needsEmailVerification) {
setError("Please verify your email before signing in");
return;
}
if (needsMfaOtp) {
// Redirect to MFA input screen
setShowMfaInput(true);
return;
}
if (!error) {
router.replace("/profile");
}
};
```
## Multi-Factor Authentication (MFA)
### TOTP Setup
The app supports Time-based One-Time Password (TOTP) authentication:
```typescript
// Generate TOTP secret and QR code
const generateMfa = async () => {
const { totpSecret, qrCodeDataUrl } = await nhost.auth.generateMfa();
// Display QR code for user to scan with authenticator app
setQrCode(qrCodeDataUrl);
setTotpSecret(totpSecret);
};
```
### MFA Verification
```typescript
// Verify TOTP code during sign-in
const verifyMfaCode = async () => {
const { error } = await nhost.auth.signInMfaTotp({
otp: mfaCode,
});
if (!error) {
router.replace("/profile");
}
};
```
### MFA Management
Users can enable/disable MFA from their profile:
```typescript
// Enable MFA with TOTP
const enableMfa = async () => {
const { error } = await nhost.auth.enableMfa({
code: totpCode,
});
};
// Disable MFA
const disableMfa = async () => {
const { error } = await nhost.auth.disableMfa({
code: totpCode,
});
};
```
## Session Management
### Custom AsyncStorage Adapter
The app uses a custom storage adapter for reliable session persistence:
```typescript
// app/lib/nhost/AsyncStorage.tsx
export default class NhostAsyncStorage implements Storage {
private cache: Map<string, string> = new Map();
setItem(key: string, value: string): void {
this.cache.set(key, value);
AsyncStorage.setItem(key, value).catch(console.error);
}
getItem(key: string): string | null {
return this.cache.get(key) || null;
}
removeItem(key: string): void {
this.cache.delete(key);
AsyncStorage.removeItem(key).catch(console.error);
}
}
```
### Features
1. **In-Memory Cache**: Provides synchronous access for Nhost while using AsyncStorage
2. **Persistence**: Sessions survive app restarts and background/foreground cycles
3. **Error Handling**: Graceful fallback if AsyncStorage operations fail
4. **Expo Go Compatible**: Works reliably in both Expo Go and standalone builds
## Error Handling
### Common Authentication Errors
```typescript
const handleAuthError = (error: any) => {
switch (error?.message) {
case "Invalid email or password":
setError("Please check your email and password");
break;
case "Email not verified":
setError("Please verify your email before signing in");
break;
case "Invalid MFA code":
setError("Please enter a valid 6-digit code");
break;
default:
setError("An unexpected error occurred");
}
};
```
### Network and Storage Errors
The app handles various error scenarios:
- Network connectivity issues
- AsyncStorage failures
- Nhost service unavailability
- Invalid authentication tokens
## Security Considerations
### Best Practices Implemented
1. **Secure Storage**: Sessions are stored securely using AsyncStorage
2. **Token Validation**: Automatic token refresh and validation
3. **Route Protection**: Server-side validation of protected routes
4. **MFA Support**: Additional security layer with TOTP
5. **Session Expiry**: Automatic logout when sessions expire
### Password Requirements
Configure password requirements in your Nhost dashboard:
- Minimum length
- Character complexity
- Common password prevention
- Breach database checking
## Testing Authentication
### Test Scenarios
1. **Valid Credentials**: Test successful sign-in with correct email/password
2. **Invalid Credentials**: Test error handling with wrong credentials
3. **Unverified Email**: Test flow for users who haven't verified email
4. **MFA Flow**: Test sign-in with MFA enabled
5. **Session Persistence**: Test app restart with active session
6. **Network Errors**: Test offline scenarios and poor connectivity
### Debug Tools
Enable debug mode to see authentication state changes:
```typescript
// Add to AuthProvider for debugging
useEffect(() => {
if (__DEV__) {
console.log("Auth state changed:", { isAuthenticated, user: user?.email });
}
}, [isAuthenticated, user]);
```
## Configuration
### Required Setup
1. **Email Provider**: Configure email provider in Nhost dashboard
2. **Email Templates**: Customize verification and welcome emails
3. **Password Policy**: Set password requirements
4. **MFA Settings**: Enable TOTP in authentication settings
### Environment Variables
```json
// app.json
{
"extra": {
"NHOST_SUBDOMAIN": "your-project-subdomain",
"NHOST_REGION": "your-region"
}
}
```
## Troubleshooting
### Common Issues
1. **Session Not Persisting**: Check AsyncStorage permissions and implementation
2. **Infinite Loading**: Verify Nhost configuration and network connectivity
3. **MFA Not Working**: Ensure time synchronization between device and server
4. **Redirect Loops**: Check protected route logic and authentication state
### Debug Steps
1. Check console logs for authentication errors
2. Verify Nhost dashboard configuration
3. Test with simple email/password flow first
4. Gradually add complexity (MFA, protected routes)
## Related Documentation
- [Native Authentication](./README_NATIVE_AUTHENTICATION.md)
- [Magic Links](./README_MAGIC_LINKS.md)
- [Social Sign-In](./README_SOCIAL_SIGNIN.md)

View File

@@ -0,0 +1,530 @@
# Social Sign-In with GitHub
This document explains how social authentication with GitHub is implemented in the Nhost React Native demo, including OAuth flow, deep linking, verification endpoints, and configuration requirements.
## Overview
Social sign-in with GitHub provides users with a seamless authentication experience using their existing GitHub accounts. The implementation handles OAuth 2.0 flow, deep linking for mobile apps, and secure token exchange through Nhost's authentication system.
## OAuth 2.0 Flow
### Authentication Process
1. **OAuth Initiation**: App redirects user to GitHub OAuth page
2. **User Authorization**: User grants permissions to the app on GitHub
3. **Authorization Code**: GitHub redirects back with authorization code
4. **Token Exchange**: Nhost exchanges code for access token
5. **User Profile**: Nhost fetches user profile from GitHub
6. **Session Creation**: Nhost creates authenticated session for the user
## Implementation Details
### SocialLoginForm Component
```typescript
// app/components/SocialLoginForm.tsx
export default function SocialLoginForm({
action,
isLoading: initialLoading = false,
}: SocialLoginFormProps) {
const { nhost } = useAuth();
const [isLoading] = useState(initialLoading);
const handleSocialLogin = (provider: "github") => {
// Create redirect URL for current environment
const redirectUrl = Linking.createURL("verify");
// Generate OAuth URL with provider and redirect
const url = nhost.auth.signInProviderURL(provider, {
redirectTo: redirectUrl,
});
// Open GitHub OAuth in system browser
void Linking.openURL(url);
};
return (
<View style={styles.socialContainer}>
<Text style={styles.socialText}>
{action} using your Social account
</Text>
<TouchableOpacity
style={styles.socialButton}
onPress={() => handleSocialLogin("github")}
disabled={isLoading}
>
<View style={styles.buttonContent}>
<Ionicons name="logo-github" size={22} style={styles.githubIcon} />
<Text style={styles.socialButtonText}>Continue with GitHub</Text>
</View>
</TouchableOpacity>
</View>
);
}
```
### Key Features
1. **Dynamic URL Generation**: Automatically creates correct redirect URLs for different environments
2. **Provider Flexibility**: Easily extensible to support additional OAuth providers
3. **Visual Feedback**: Loading states and branded buttons for better UX
4. **Error Handling**: Graceful handling of OAuth failures and cancellations
## Deep Linking Configuration
### URL Scheme Setup
The app supports deep linking to handle OAuth redirects:
```json
// app.json
{
"expo": {
"scheme": "reactnativewebdemo",
"ios": {
"infoPlist": {
"CFBundleURLTypes": [
{
"CFBundleURLSchemes": ["reactnativewebdemo"]
}
]
}
}
}
}
```
### Redirect URL Formats
#### Standalone App
```
reactnativewebdemo://verify?refreshToken=abc123...&type=signup
```
#### Expo Go Development
```
exp://192.168.1.103:19000/--/verify?refreshToken=abc123...&type=signup
```
### Environment-Aware URL Generation
```typescript
// Automatically handles environment differences
const redirectUrl = Linking.createURL("verify");
// Creates appropriate URL for current environment:
// - Expo Go: exp://host:port/--/verify
// - Standalone: reactnativewebdemo://verify
```
## GitHub OAuth Configuration
### Nhost Dashboard Setup
Configure GitHub as an OAuth provider in your Nhost dashboard:
1. **Provider**: Enable GitHub in Authentication > Providers
2. **Client ID**: GitHub OAuth App Client ID
3. **Client Secret**: GitHub OAuth App Client Secret
4. **Redirect URL**: Configure allowed redirect URLs
### GitHub OAuth App Setup
Create a GitHub OAuth App in your GitHub Developer Settings:
1. **Application Name**: Your app name
2. **Homepage URL**: Your app's homepage
3. **Authorization Callback URL**:
- Development: `https://local.auth.nhost.run/v1/auth/providers/github/callback`
- Production: `https://[subdomain].auth.[region].nhost.run/v1/auth/providers/github/callback`
### Required GitHub Scopes
The app requests these GitHub scopes:
- `user:email` - Access to user's email addresses
- `read:user` - Access to user profile information
## Verification Flow
### Verify Screen Implementation
```typescript
// app/verify.tsx - Handles both magic links and social auth
export default function Verify() {
const params = useLocalSearchParams<{
refreshToken: string;
type?: string;
}>();
const [status, setStatus] = useState<"verifying" | "success" | "error">(
"verifying",
);
const { nhost, isAuthenticated } = useAuth();
useEffect(() => {
const { refreshToken, type } = params;
if (!refreshToken) {
setStatus("error");
setError("No authentication token found");
return;
}
async function processAuthentication(): Promise<void> {
try {
// Show verifying state briefly
await new Promise((resolve) => setTimeout(resolve, 500));
// Authenticate using refresh token from OAuth flow
await nhost.auth.refreshToken({ refreshToken });
setStatus("success");
// Redirect after showing success message
setTimeout(() => {
router.replace("/profile");
}, 1500);
} catch (err) {
setStatus("error");
setError(`Authentication failed: ${err.message}`);
}
}
processAuthentication();
}, [params, nhost.auth]);
// Redirect if already authenticated
useEffect(() => {
if (isAuthenticated && status !== "verifying") {
router.replace("/profile");
}
}, [isAuthenticated, status]);
// ... UI implementation for different states
}
```
### Authentication States
1. **Verifying**: Processing OAuth callback and exchanging tokens
2. **Success**: Authentication completed successfully
3. **Error**: OAuth flow failed or was cancelled
## User Data Handling
### GitHub Profile Information
When users authenticate with GitHub, Nhost receives:
```typescript
// User profile data from GitHub
interface GitHubUser {
id: string;
email: string;
displayName: string;
avatarUrl?: string;
metadata: {
github: {
id: number;
login: string;
name: string;
company?: string;
blog?: string;
location?: string;
bio?: string;
public_repos: number;
followers: number;
following: number;
created_at: string;
};
};
}
```
### Accessing User Data
```typescript
// Access GitHub-specific user data
const { user } = useAuth();
if (user?.metadata?.github) {
const githubData = user.metadata.github;
console.log("GitHub username:", githubData.login);
console.log("Public repos:", githubData.public_repos);
console.log("Followers:", githubData.followers);
}
```
## Error Handling
### OAuth Flow Errors
```typescript
// Common OAuth error scenarios
const handleOAuthErrors = (error: any) => {
switch (error.type) {
case "access_denied":
// User denied permission
Alert.alert("Access Denied", "You need to grant permission to continue");
break;
case "invalid_request":
// Malformed OAuth request
Alert.alert("Error", "Invalid authentication request");
break;
case "server_error":
// GitHub server error
Alert.alert("Error", "GitHub is temporarily unavailable");
break;
case "temporarily_unavailable":
// Service temporarily unavailable
Alert.alert("Error", "Authentication service is busy. Please try again.");
break;
default:
Alert.alert("Error", "Authentication failed. Please try again.");
}
};
```
### Network and Integration Errors
```typescript
// Handle Nhost integration errors
const processOAuthCallback = async (refreshToken: string) => {
try {
await nhost.auth.refreshToken({ refreshToken });
} catch (error) {
if (error.message.includes("Invalid refresh token")) {
throw new Error("Authentication session expired. Please try again.");
}
if (error.message.includes("Network")) {
throw new Error("Network error. Please check your connection.");
}
throw new Error("Authentication failed. Please try again.");
}
};
```
## Testing Social Authentication
### Development Testing
1. **Start Development Server**:
```bash
npx expo start
```
2. **Configure Test Environment**:
- Ensure GitHub OAuth app has correct callback URL
- Verify Nhost GitHub provider configuration
- Check network connectivity between device and development server
3. **Test OAuth Flow**:
- Tap "Continue with GitHub" button
- Should open system browser with GitHub OAuth page
- Log in with GitHub credentials
- Grant permissions to the app
- Should redirect back to app and authenticate
### Test Scenarios
```typescript
// Test different OAuth scenarios
const testScenarios = [
"First-time GitHub authentication",
"Returning GitHub user",
"User cancels OAuth flow",
"User denies permissions",
"GitHub account with 2FA enabled",
"Network connection issues during OAuth",
"Invalid OAuth configuration",
"Expired OAuth session",
];
```
### Manual Testing Checklist
- [ ] GitHub OAuth button appears and is clickable
- [ ] Clicking button opens system browser
- [ ] GitHub login page loads correctly
- [ ] Successfully logging in redirects back to app
- [ ] App shows verification screen briefly
- [ ] User is redirected to profile after authentication
- [ ] User data is correctly populated from GitHub
- [ ] Canceling OAuth flow handles gracefully
- [ ] Network errors are handled appropriately
## Security Considerations
### OAuth Security Best Practices
1. **HTTPS Only**: All OAuth URLs use HTTPS for secure communication
2. **State Parameter**: Nhost includes state parameter to prevent CSRF attacks
3. **Short-Lived Tokens**: Authorization codes have short expiration times
4. **Secure Storage**: Refresh tokens are stored securely by Nhost
5. **Scope Limitation**: Only request necessary permissions from GitHub
### Token Security
```typescript
// Nhost handles secure token management
// - Authorization codes are exchanged server-side
// - Access tokens are not exposed to client
// - Refresh tokens are securely stored
// - Session management is handled automatically
```
## Troubleshooting
### Common Issues
| Issue | Symptom | Solution |
| ---------------------------- | --------------------------------------- | ---------------------------------------------------------------- |
| OAuth redirect doesn't work | Browser opens but doesn't return to app | Check URL scheme configuration and GitHub OAuth app callback URL |
| "Invalid client" error | GitHub shows OAuth error page | Verify GitHub OAuth app Client ID in Nhost dashboard |
| "Redirect URI mismatch" | GitHub rejects OAuth request | Ensure callback URL in GitHub app matches Nhost configuration |
| App doesn't open after OAuth | Browser stays open after GitHub login | Check deep linking configuration and app installation |
### Debug Steps
1. **Check OAuth URL**:
```typescript
const url = nhost.auth.signInProviderURL("github", {
redirectTo: redirectUrl,
});
console.log("OAuth URL:", url);
```
2. **Verify Redirect URL**:
```typescript
const redirectUrl = Linking.createURL("verify");
console.log("Redirect URL:", redirectUrl);
```
3. **Check URL Parameters**:
```typescript
// In verify screen
console.log("Received parameters:", params);
```
4. **Test Manual URL**:
- Copy OAuth URL from console
- Paste into browser to test flow manually
### GitHub-Specific Debugging
1. **Check GitHub OAuth App Settings**:
- Verify callback URLs are correctly configured
- Ensure app is not suspended or restricted
2. **Monitor GitHub OAuth Logs**:
- Check GitHub OAuth app's activity logs
- Look for failed authorization attempts
3. **Validate GitHub Scopes**:
- Ensure requested scopes match app requirements
- Check if user has granted necessary permissions
## Production Deployment
### GitHub OAuth App Configuration
For production deployment:
1. **Production Callback URL**:
```
https://[subdomain].auth.[region].nhost.run/v1/auth/providers/github/callback
```
2. **Homepage URL**: Set to your production app's homepage
3. **Application Description**: Provide clear description of your app's purpose
### Universal Links Setup
Configure universal links for seamless production experience:
```json
// apple-app-site-association (iOS)
{
"applinks": {
"apps": [],
"details": [
{
"appID": "TEAMID.com.nhost.reactnativewebdemo",
"paths": ["/verify*", "/auth/callback*"]
}
]
}
}
```
### Security Hardening
1. **Environment Variables**: Store sensitive OAuth credentials securely
2. **Domain Validation**: Implement additional domain validation for callbacks
3. **Rate Limiting**: Configure rate limiting for OAuth endpoints
4. **Monitoring**: Set up monitoring for failed OAuth attempts
## Extending to Other Providers
### Adding New OAuth Providers
The implementation can be easily extended to support other providers:
```typescript
// Extended provider support
type SocialProvider = "github" | "google" | "facebook" | "discord";
const handleSocialLogin = (provider: SocialProvider) => {
const redirectUrl = Linking.createURL("verify");
const url = nhost.auth.signInProviderURL(provider, {
redirectTo: redirectUrl,
});
void Linking.openURL(url);
};
// Provider-specific UI
const getProviderIcon = (provider: SocialProvider) => {
switch (provider) {
case "github":
return "logo-github";
case "google":
return "logo-google";
case "facebook":
return "logo-facebook";
case "discord":
return "logo-discord";
}
};
```
## Related Documentation
- [Protected Routes & Email Auth](./README_PROTECTED_ROUTES.md)
- [Native Authentication](./README_NATIVE_AUTHENTICATION.md)
- [Magic Links](./README_MAGIC_LINKS.md)
## External Resources
- [GitHub OAuth Documentation](https://docs.github.com/en/developers/apps/building-oauth-apps)
- [Nhost Social Authentication](https://docs.nhost.io/authentication/social-login)
- [OAuth 2.0 Security Best Practices](https://tools.ietf.org/html/draft-ietf-oauth-security-topics)
- [Expo AuthSession](https://docs.expo.dev/versions/latest/sdk/auth-session/)
- [React Native Deep Linking](https://reactnative.dev/docs/linking)

View File

@@ -0,0 +1,53 @@
{
"expo": {
"name": "ReactNativeWebDemo",
"slug": "ReactNativeWebDemo",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "reactnativewebdemo",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.nhost.reactnativewebdemo",
"jsEngine": "jsc",
"infoPlist": {
"NSFaceIDUsageDescription": "This app uses Face ID for signing in",
"CFBundleURLTypes": [
{
"CFBundleURLSchemes": ["reactnativewebdemo"]
}
]
}
},
"android": {
"jsEngine": "jsc",
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"edgeToEdgeEnabled": true
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff"
}
],
["expo-apple-authentication"]
],
"experiments": {
"typedRoutes": true
},
"extra": {
"NHOST_REGION": "local",
"NHOST_SUBDOMAIN": "192-168-1-103"
}
}
}

View File

@@ -0,0 +1,54 @@
import { Stack } from "expo-router";
import { Text, View } from "react-native";
import { AuthProvider } from "./lib/nhost/AuthProvider";
export default function RootLayout() {
return (
<AuthProvider>
<Stack
screenOptions={{
headerStyle: {
backgroundColor: "#f5f5f5",
},
headerTintColor: "#333",
headerTitleStyle: {
fontWeight: "bold",
},
}}
>
<Stack.Screen name="index" options={{ title: "Home" }} />
<Stack.Screen name="signin" options={{ title: "Sign In" }} />
<Stack.Screen
name="signin/mfa"
options={{ title: "MFA Verification" }}
/>
<Stack.Screen name="signup" options={{ title: "Sign Up" }} />
<Stack.Screen name="profile" options={{ title: "Profile" }} />
<Stack.Screen name="upload" options={{ title: "File Upload" }} />
<Stack.Screen name="verify" options={{ title: "Verify Email" }} />
</Stack>
</AuthProvider>
);
}
// Error boundary to catch and display errors
export function ErrorBoundary(props: { error: Error }) {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 20,
}}
>
<Text style={{ fontSize: 18, fontWeight: "bold", marginBottom: 10 }}>
An error occurred
</Text>
<Text style={{ color: "red", marginBottom: 10 }}>
{props.error.message}
</Text>
<Text>{props.error.stack}</Text>
</View>
);
}

View File

@@ -0,0 +1,112 @@
import * as AppleAuthentication from "expo-apple-authentication";
import * as Crypto from "expo-crypto";
import { router } from "expo-router";
import React from "react";
import { Alert, Platform, StyleSheet } from "react-native";
import { useAuth } from "../lib/nhost/AuthProvider";
interface AppleSignInProps {
action: "Sign In" | "Sign Up";
isLoading: boolean;
setIsLoading: (isLoading: boolean) => void;
}
const AppleSignIn: React.FC<AppleSignInProps> = ({ setIsLoading }) => {
const { nhost } = useAuth();
// Check if Apple authentication is available on this device
const [appleAuthAvailable, setAppleAuthAvailable] = React.useState(false);
React.useEffect(() => {
const checkAvailability = async () => {
if (Platform.OS === "ios") {
const isAvailable = await AppleAuthentication.isAvailableAsync();
setAppleAuthAvailable(isAvailable);
}
};
void checkAvailability();
}, []);
const handleAppleSignIn = async () => {
try {
setIsLoading(true);
const nonce = Math.random().toString(36).substring(2, 15);
// Hash the nonce for Apple Authentication
const hashedNonce = await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
nonce,
);
// Request Apple authentication with our hashed nonce
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
nonce: hashedNonce,
});
if (credential.identityToken) {
// Use the identity token to sign in with Nhost
// Pass the original unhashed nonce to the SDK
// so the server can verify it
const response = await nhost.auth.signInIdToken({
provider: "apple",
idToken: credential.identityToken,
nonce,
});
if (response.body?.session) {
router.replace("/profile");
} else {
Alert.alert(
"Authentication Error",
"Failed to authenticate with Nhost",
);
}
} else {
Alert.alert(
"Authentication Error",
"No identity token received from Apple",
);
}
} catch (error: unknown) {
// Handle other errors
const message =
error instanceof Error
? error.message
: "Failed to authentica with Apple";
Alert.alert("Authentication Error", message);
} finally {
setIsLoading(false);
}
};
// Only show the button on iOS devices where Apple authentication is available
if (Platform.OS !== "ios" || !appleAuthAvailable) {
return null;
}
return (
<AppleAuthentication.AppleAuthenticationButton
buttonType={AppleAuthentication.AppleAuthenticationButtonType.SIGN_IN}
buttonStyle={AppleAuthentication.AppleAuthenticationButtonStyle.BLACK}
cornerRadius={5}
style={styles.appleButton}
onPress={handleAppleSignIn}
/>
);
};
const styles = StyleSheet.create({
appleButton: {
width: "100%",
height: 45,
marginBottom: 10,
},
});
export default AppleSignIn;

View File

@@ -0,0 +1,594 @@
import type { ErrorResponse } from "@nhost/nhost-js/auth";
import type { FetchError } from "@nhost/nhost-js/fetch";
import * as Clipboard from "expo-clipboard";
import { useEffect, useState } from "react";
import {
ActivityIndicator,
Alert,
Dimensions,
Image,
Keyboard,
KeyboardAvoidingView,
Modal,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
TouchableWithoutFeedback,
View,
} from "react-native";
import { useAuth } from "../lib/nhost/AuthProvider";
interface MFASettingsProps {
initialMfaEnabled: boolean;
}
export default function MFASettings({ initialMfaEnabled }: MFASettingsProps) {
const [isMfaEnabled, setIsMfaEnabled] = useState<boolean>(initialMfaEnabled);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const { nhost } = useAuth();
// Update internal state when prop changes
useEffect(() => {
if (initialMfaEnabled !== isMfaEnabled) {
setIsMfaEnabled(initialMfaEnabled);
}
}, [initialMfaEnabled, isMfaEnabled]);
// MFA setup states
const [isSettingUpMfa, setIsSettingUpMfa] = useState<boolean>(false);
const [totpSecret, setTotpSecret] = useState<string>("");
const [qrCodeUrl, setQrCodeUrl] = useState<string>("");
const [verificationCode, setVerificationCode] = useState<string>("");
const [qrCodeModalVisible, setQrCodeModalVisible] = useState<boolean>(false);
// Disabling MFA states
const [isDisablingMfa, setIsDisablingMfa] = useState<boolean>(false);
const [disableVerificationCode, setDisableVerificationCode] =
useState<string>("");
// Begin MFA setup process
const handleEnableMfa = async () => {
setIsLoading(true);
setError(null);
setSuccess(null);
try {
// Generate TOTP secret
const response = await nhost.auth.changeUserMfa();
setTotpSecret(response.body.totpSecret);
setQrCodeUrl(response.body.imageUrl);
setIsSettingUpMfa(true);
} catch (err) {
const errMessage = err instanceof Error ? err.message : "Unknown error";
setError(`An error occurred while enabling MFA: ${errMessage}`);
Alert.alert("Error", `Failed to enable MFA: ${errMessage}`);
} finally {
setIsLoading(false);
}
};
// Verify TOTP and enable MFA
const handleVerifyTotp = async () => {
if (!verificationCode) {
setError("Please enter the verification code");
Alert.alert("Error", "Please enter the verification code");
return;
}
setIsLoading(true);
setError(null);
setSuccess(null);
try {
// Verify and activate MFA
await nhost.auth.verifyChangeUserMfa({
activeMfaType: "totp",
code: verificationCode,
});
setIsMfaEnabled(true);
setIsSettingUpMfa(false);
setSuccess("MFA has been successfully enabled.");
Alert.alert("Success", "MFA has been successfully enabled.");
} catch (err) {
const errMessage = err instanceof Error ? err.message : "Unknown error";
setError(`An error occurred while verifying the code: ${errMessage}`);
Alert.alert("Error", `Failed to verify code: ${errMessage}`);
} finally {
setIsLoading(false);
}
};
// Show disable MFA confirmation
const handleShowDisableMfa = () => {
setIsDisablingMfa(true);
setError(null);
setSuccess(null);
};
// Disable MFA
const handleDisableMfa = async () => {
if (!disableVerificationCode) {
setError("Please enter your verification code to confirm");
Alert.alert("Error", "Please enter your verification code to confirm");
return;
}
setIsLoading(true);
setError(null);
setSuccess(null);
try {
// Disable MFA by setting activeMfaType to empty string
await nhost.auth.verifyChangeUserMfa({
activeMfaType: "",
code: disableVerificationCode,
});
setIsMfaEnabled(false);
setIsDisablingMfa(false);
setDisableVerificationCode("");
setSuccess("MFA has been successfully disabled.");
Alert.alert("Success", "MFA has been successfully disabled.");
} catch (err) {
const error = err as FetchError<ErrorResponse>;
setError(`An error occurred while disabling MFA: ${error.message}`);
Alert.alert("Error", `Failed to disable MFA: ${error.message}`);
} finally {
setIsLoading(false);
}
};
// Cancel MFA setup
const handleCancelMfaSetup = () => {
setIsSettingUpMfa(false);
setTotpSecret("");
setQrCodeUrl("");
setVerificationCode("");
};
// Cancel MFA disable
const handleCancelMfaDisable = () => {
setIsDisablingMfa(false);
setDisableVerificationCode("");
setError(null);
};
return (
<View style={styles.container}>
<Text style={styles.title}>Multi-Factor Authentication</Text>
{error && (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{error}</Text>
</View>
)}
{success && (
<View style={styles.successContainer}>
<Text style={styles.successText}>{success}</Text>
</View>
)}
{isSettingUpMfa ? (
<KeyboardAvoidingView behavior="padding" style={{ flex: 1 }}>
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={styles.contentContainer}
>
<Text style={styles.instructionText}>
Scan this QR code with your authenticator app (e.g., Google
Authenticator, Authy):
</Text>
{qrCodeUrl && (
<TouchableOpacity
style={styles.qrCodeContainer}
onPress={() => setQrCodeModalVisible(true)}
>
<Image
source={{ uri: qrCodeUrl }}
style={styles.qrCode}
resizeMode="contain"
/>
<Text style={styles.copyHint}>(Tap to enlarge)</Text>
</TouchableOpacity>
)}
<Modal
animationType="slide"
transparent={true}
visible={qrCodeModalVisible}
onRequestClose={() => setQrCodeModalVisible(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<Text style={styles.modalTitle}>Scan QR Code</Text>
<Image
source={{ uri: qrCodeUrl }}
style={styles.largeQrCode}
resizeMode="contain"
/>
<TouchableOpacity
style={styles.closeButton}
onPress={() => setQrCodeModalVisible(false)}
>
<Text style={styles.closeButtonText}>Close</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
<Text style={styles.instructionText}>
Or manually enter this secret key:
</Text>
<TouchableOpacity
style={styles.secretContainer}
onPress={async () => {
await Clipboard.setStringAsync(totpSecret);
Alert.alert("Copied", "Secret key copied to clipboard");
}}
>
<Text style={styles.secretText}>{totpSecret}</Text>
<Text style={styles.copyHint}>(Tap to copy)</Text>
</TouchableOpacity>
<View style={styles.inputContainer}>
<Text style={styles.label}>Verification Code</Text>
<TextInput
style={styles.input}
value={verificationCode}
onChangeText={setVerificationCode}
placeholder="Enter 6-digit code"
maxLength={6}
keyboardType="number-pad"
returnKeyType="done"
/>
</View>
<View style={[styles.buttonRow, { marginBottom: 30 }]}>
<TouchableOpacity
style={[
styles.button,
styles.primaryButton,
(!verificationCode || isLoading) && styles.disabledButton,
]}
onPress={handleVerifyTotp}
disabled={isLoading || !verificationCode}
>
{isLoading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.buttonText}>Verify and Enable</Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.secondaryButton]}
onPress={handleCancelMfaSetup}
disabled={isLoading}
>
<Text style={styles.secondaryButtonText}>Cancel</Text>
</TouchableOpacity>
</View>
</ScrollView>
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
) : isDisablingMfa ? (
<KeyboardAvoidingView behavior="padding" style={{ flex: 1 }}>
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={styles.contentContainer}
>
<Text style={styles.instructionText}>
To disable Multi-Factor Authentication, please enter the current
verification code from your authenticator app.
</Text>
<View style={styles.inputContainer}>
<Text style={styles.label}>Current Verification Code</Text>
<TextInput
style={styles.input}
value={disableVerificationCode}
onChangeText={setDisableVerificationCode}
placeholder="Enter 6-digit code"
maxLength={6}
keyboardType="number-pad"
returnKeyType="done"
/>
</View>
<View style={[styles.buttonRow, { marginBottom: 30 }]}>
<TouchableOpacity
style={[
styles.button,
styles.primaryButton,
(!disableVerificationCode || isLoading) &&
styles.disabledButton,
]}
onPress={handleDisableMfa}
disabled={isLoading || !disableVerificationCode}
>
{isLoading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.buttonText}>Confirm Disable</Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.secondaryButton]}
onPress={handleCancelMfaDisable}
disabled={isLoading}
>
<Text style={styles.secondaryButtonText}>Cancel</Text>
</TouchableOpacity>
</View>
</ScrollView>
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
) : (
<View style={styles.contentContainer}>
<Text style={styles.instructionText}>
Multi-Factor Authentication adds an extra layer of security to your
account by requiring a verification code from your authenticator app
when signing in.
</Text>
<View style={styles.statusContainer}>
<Text style={styles.statusLabel}>Status:</Text>
<Text
style={[
styles.statusValue,
isMfaEnabled ? styles.enabledText : styles.disabledText,
]}
>
{isMfaEnabled ? "Enabled" : "Disabled"}
</Text>
</View>
{isMfaEnabled ? (
<TouchableOpacity
style={[
styles.button,
styles.secondaryButton,
isLoading && styles.disabledButton,
]}
onPress={handleShowDisableMfa}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator size="small" color="#6366f1" />
) : (
<Text style={styles.secondaryButtonText}>Disable MFA</Text>
)}
</TouchableOpacity>
) : (
<TouchableOpacity
style={[
styles.button,
styles.primaryButton,
isLoading && styles.disabledButton,
]}
onPress={handleEnableMfa}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.buttonText}>Enable MFA</Text>
)}
</TouchableOpacity>
)}
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: "#fff",
borderRadius: 10,
padding: 16,
marginBottom: 20,
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 3.84,
elevation: 5,
flex: 1,
},
title: {
fontSize: 18,
fontWeight: "bold",
marginBottom: 16,
color: "#333",
},
contentContainer: {
marginTop: 10,
},
errorContainer: {
backgroundColor: "#fee2e2",
padding: 12,
borderRadius: 6,
marginBottom: 16,
borderLeftWidth: 4,
borderLeftColor: "#ef4444",
},
errorText: {
color: "#b91c1c",
fontSize: 14,
},
successContainer: {
backgroundColor: "#dcfce7",
padding: 12,
borderRadius: 6,
marginBottom: 16,
borderLeftWidth: 4,
borderLeftColor: "#10b981",
},
successText: {
color: "#047857",
fontSize: 14,
},
instructionText: {
fontSize: 14,
color: "#4b5563",
marginBottom: 16,
},
qrCodeContainer: {
alignItems: "center",
justifyContent: "center",
backgroundColor: "#fff",
padding: 10,
marginVertical: 16,
borderRadius: 8,
borderWidth: 1,
borderColor: "#e5e7eb",
},
qrCode: {
width: 200,
height: 200,
},
modalOverlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.5)",
justifyContent: "center",
alignItems: "center",
},
modalContent: {
backgroundColor: "white",
borderRadius: 12,
padding: 20,
alignItems: "center",
elevation: 5,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 3.84,
width: Dimensions.get("window").width * 0.9,
},
largeQrCode: {
width: Dimensions.get("window").width * 0.7,
height: Dimensions.get("window").width * 0.7,
marginVertical: 20,
},
modalTitle: {
fontSize: 18,
fontWeight: "bold",
marginBottom: 15,
},
closeButton: {
backgroundColor: "#6366f1",
paddingVertical: 12,
paddingHorizontal: 30,
borderRadius: 6,
},
closeButtonText: {
color: "white",
fontWeight: "600",
},
secretContainer: {
backgroundColor: "#f3f4f6",
padding: 12,
borderRadius: 6,
marginBottom: 16,
alignItems: "center",
borderWidth: 1,
borderColor: "#d1d5db",
borderStyle: "dashed",
},
secretText: {
fontFamily: "monospace",
fontSize: 14,
color: "#111827",
marginBottom: 4,
},
copyHint: {
fontSize: 12,
color: "#6366f1",
fontStyle: "italic",
},
inputContainer: {
marginBottom: 16,
},
label: {
fontSize: 14,
fontWeight: "500",
marginBottom: 8,
color: "#374151",
},
input: {
borderWidth: 1,
borderColor: "#d1d5db",
borderRadius: 6,
padding: 10,
fontSize: 16,
backgroundColor: "#f9fafb",
},
buttonRow: {
flexDirection: "row",
justifyContent: "space-between",
marginTop: 8,
},
button: {
flex: 1,
padding: 12,
borderRadius: 6,
alignItems: "center",
justifyContent: "center",
},
primaryButton: {
backgroundColor: "#6366f1",
marginRight: 8,
},
secondaryButton: {
backgroundColor: "#f3f4f6",
marginLeft: 8,
borderWidth: 1,
borderColor: "#d1d5db",
},
disabledButton: {
opacity: 0.5,
},
buttonText: {
color: "#fff",
fontWeight: "600",
fontSize: 14,
},
secondaryButtonText: {
color: "#4b5563",
fontWeight: "600",
fontSize: 14,
},
statusContainer: {
flexDirection: "row",
alignItems: "center",
marginBottom: 20,
},
statusLabel: {
fontSize: 14,
color: "#4b5563",
marginRight: 8,
},
statusValue: {
fontSize: 14,
fontWeight: "600",
},
enabledText: {
color: "#10b981",
},
disabledText: {
color: "#f59e0b",
},
});

View File

@@ -0,0 +1,158 @@
import type { ErrorResponse } from "@nhost/nhost-js/auth";
import type { FetchError } from "@nhost/nhost-js/fetch";
import * as Linking from "expo-linking";
import { useState } from "react";
import {
ActivityIndicator,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { useAuth } from "../lib/nhost/AuthProvider";
interface MagicLinkFormProps {
buttonLabel?: string;
}
export default function MagicLinkForm({
buttonLabel = "Send Magic Link",
}: MagicLinkFormProps) {
const [email, setEmail] = useState<string>("");
const [isLoading, setIsLoading] = useState<boolean>(false);
const [success, setSuccess] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const { nhost } = useAuth();
const handleSubmit = async () => {
setIsLoading(true);
setError(null);
try {
// For Expo Go, we need to create the correct URL format
// This will work both in Expo Go and standalone app
const redirectUrl = Linking.createURL("verify");
await nhost.auth.signInPasswordlessEmail({
email,
options: {
redirectTo: redirectUrl,
},
});
setSuccess(true);
} catch (err) {
const error = err as FetchError<ErrorResponse>;
setError(
`An error occurred while sending the magic link: ${error.message}`,
);
} finally {
setIsLoading(false);
}
};
if (success) {
return (
<View style={styles.container}>
<Text style={styles.successText}>
Magic link sent! Check your email to sign in.
</Text>
<TouchableOpacity
style={styles.secondaryButton}
onPress={() => setSuccess(false)}
>
<Text style={styles.secondaryButtonText}>Try again</Text>
</TouchableOpacity>
</View>
);
}
return (
<View style={styles.container}>
<View style={styles.inputGroup}>
<Text style={styles.label}>Email</Text>
<TextInput
style={styles.input}
value={email}
onChangeText={setEmail}
placeholder="Enter your email"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
/>
</View>
{error && <Text style={styles.errorText}>{error}</Text>}
<TouchableOpacity
style={styles.button}
onPress={handleSubmit}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.buttonText}>{buttonLabel}</Text>
)}
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: {
width: "100%",
},
inputGroup: {
marginBottom: 15,
},
label: {
fontSize: 16,
marginBottom: 5,
color: "#333",
},
input: {
height: 45,
borderWidth: 1,
borderColor: "#ddd",
borderRadius: 5,
paddingHorizontal: 10,
fontSize: 16,
backgroundColor: "#fafafa",
},
errorText: {
color: "#e53e3e",
marginBottom: 10,
},
successText: {
fontSize: 16,
color: "#38a169",
textAlign: "center",
marginBottom: 15,
},
button: {
backgroundColor: "#6366f1",
paddingVertical: 12,
borderRadius: 5,
alignItems: "center",
marginTop: 10,
},
buttonText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
secondaryButton: {
backgroundColor: "#e2e8f0",
paddingVertical: 12,
borderRadius: 5,
alignItems: "center",
marginTop: 10,
},
secondaryButtonText: {
color: "#4a5568",
fontSize: 16,
fontWeight: "600",
},
});

View File

@@ -0,0 +1,93 @@
import {
ActivityIndicator,
Platform,
StyleSheet,
Text,
View,
} from "react-native";
import AppleSignIn from "./AppleSignIn";
interface NativeLoginFormProps {
action: "Sign In" | "Sign Up";
isLoading: boolean;
setAppleAuthInProgress: (inProgress: boolean) => void;
}
export default function NativeLoginForm({
action,
isLoading,
setAppleAuthInProgress,
}: NativeLoginFormProps) {
// Function to update loading state
const updateLoadingState = (loading: boolean) => {
if (setAppleAuthInProgress) {
setAppleAuthInProgress(loading);
}
};
// Check if we have any native options for this platform
const hasAppleOption = Platform.OS === "ios";
return (
<View style={styles.container}>
<Text style={styles.text}>
{action} using native authentication methods
</Text>
{isLoading ? (
<ActivityIndicator size="large" color="#6366f1" />
) : (
<View style={styles.buttonContainer}>
<AppleSignIn
action={action}
isLoading={isLoading}
setIsLoading={updateLoadingState}
/>
{!hasAppleOption && (
<Text style={styles.noOptionsText}>
No native authentication options available for your platform
</Text>
)}
{hasAppleOption && (
<Text style={styles.infoText}>
Native sign-in methods provide a more streamlined authentication
experience
</Text>
)}
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
alignItems: "center",
paddingVertical: 10,
width: "100%",
},
text: {
fontSize: 16,
marginBottom: 20,
textAlign: "center",
color: "#4a5568",
},
buttonContainer: {
width: "100%",
alignItems: "center",
},
infoText: {
marginTop: 10,
fontSize: 12,
color: "#718096",
textAlign: "center",
},
noOptionsText: {
marginTop: 20,
fontSize: 14,
color: "#a0aec0",
textAlign: "center",
fontStyle: "italic",
},
});

View File

@@ -0,0 +1,40 @@
import { router } from "expo-router";
import type React from "react";
import { useEffect } from "react";
import { ActivityIndicator, Text, View } from "react-native";
import { useAuth } from "../lib/nhost/AuthProvider";
type AppRoutes = "/" | "/signin" | "/signup" | "/profile";
interface ProtectedScreenProps {
children: React.ReactNode;
redirectTo?: AppRoutes;
}
export default function ProtectedScreen({
children,
redirectTo = "/signin",
}: ProtectedScreenProps) {
const { isAuthenticated, isLoading } = useAuth();
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.replace(redirectTo);
}
}, [isAuthenticated, isLoading, redirectTo]);
if (isLoading) {
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<ActivityIndicator size="large" color="#0000ff" />
<Text style={{ marginTop: 10 }}>Loading...</Text>
</View>
);
}
if (!isAuthenticated) {
return null; // Will redirect in useEffect
}
return <>{children}</>;
}

View File

@@ -0,0 +1,91 @@
import { Ionicons } from "@expo/vector-icons";
import * as Linking from "expo-linking";
import { useState } from "react";
import {
ActivityIndicator,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { useAuth } from "../lib/nhost/AuthProvider";
interface SocialLoginFormProps {
action: "Sign In" | "Sign Up";
isLoading?: boolean;
}
export default function SocialLoginForm({
action,
isLoading: initialLoading = false,
}: SocialLoginFormProps) {
const { nhost } = useAuth();
const [isLoading] = useState(initialLoading);
const handleSocialLogin = (provider: "github") => {
// Use the same redirect URL approach as the magic link
const redirectUrl = Linking.createURL("verify");
// Sign in with the specified provider
const url = nhost.auth.signInProviderURL(provider, {
redirectTo: redirectUrl,
});
// Open the URL in browser
void Linking.openURL(url);
};
return (
<View style={styles.socialContainer}>
<Text style={styles.socialText}>{action} using your Social account</Text>
{isLoading ? (
<ActivityIndicator size="large" color="#6366f1" />
) : (
<TouchableOpacity
style={styles.socialButton}
onPress={() => handleSocialLogin("github")}
disabled={isLoading}
>
<View style={styles.buttonContent}>
<Ionicons name="logo-github" size={22} style={styles.githubIcon} />
<Text style={styles.socialButtonText}>Continue with GitHub</Text>
</View>
</TouchableOpacity>
)}
</View>
);
}
const styles = StyleSheet.create({
socialContainer: {
alignItems: "center",
paddingVertical: 10,
},
socialText: {
fontSize: 16,
marginBottom: 20,
textAlign: "center",
color: "#4a5568",
},
socialButton: {
backgroundColor: "#24292e",
paddingVertical: 12,
paddingHorizontal: 15,
borderRadius: 5,
width: "100%",
},
buttonContent: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
},
githubIcon: {
marginRight: 10,
color: "#ffffff",
},
socialButtonText: {
color: "#ffffff",
fontSize: 16,
fontWeight: "600",
},
});

View File

@@ -0,0 +1,120 @@
import { useRouter } from "expo-router";
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
import { useAuth } from "./lib/nhost/AuthProvider";
export default function Index() {
const router = useRouter();
const { isAuthenticated, user } = useAuth();
return (
<View style={styles.container}>
<Text style={styles.title}>Nhost SDK Demo</Text>
<Text style={styles.subtitle}>React Native Example</Text>
<View style={styles.contentContainer}>
{isAuthenticated ? (
<>
<Text style={styles.welcomeText}>
Welcome back, {user?.displayName || user?.email || "User"}!
</Text>
<TouchableOpacity
style={styles.button}
onPress={() => router.push("/profile")}
>
<Text style={styles.buttonText}>Go to Profile</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.secondaryButton]}
onPress={() => router.push("/upload")}
>
<Text style={styles.buttonText}>File Upload</Text>
</TouchableOpacity>
</>
) : (
<View style={styles.authButtons}>
<TouchableOpacity
style={styles.button}
onPress={() => router.push("/signin")}
>
<Text style={styles.buttonText}>Sign In</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.secondaryButton]}
onPress={() => router.push("/signup")}
>
<Text style={styles.buttonText}>Sign Up</Text>
</TouchableOpacity>
</View>
)}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 20,
backgroundColor: "#f5f5f5",
},
title: {
fontSize: 28,
fontWeight: "bold",
marginBottom: 8,
color: "#333",
},
subtitle: {
fontSize: 18,
marginBottom: 30,
color: "#666",
},
contentContainer: {
width: "100%",
maxWidth: 400,
backgroundColor: "#fff",
borderRadius: 10,
padding: 20,
alignItems: "center",
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 3.84,
elevation: 5,
},
welcomeText: {
fontSize: 18,
marginBottom: 20,
textAlign: "center",
},
buttonContainer: {
width: "100%",
gap: 15,
},
authButtons: {
width: "100%",
gap: 15,
},
button: {
backgroundColor: "#6366f1",
paddingVertical: 12,
paddingHorizontal: 20,
borderRadius: 8,
width: "100%",
alignItems: "center",
marginTop: 10,
},
secondaryButton: {
backgroundColor: "#818cf8",
},
buttonText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
});

View File

@@ -0,0 +1,93 @@
import {
DEFAULT_SESSION_KEY,
type Session,
type SessionStorageBackend,
} from "@nhost/nhost-js/session";
import AsyncStorage from "@react-native-async-storage/async-storage";
/**
* Custom storage implementation for React Native using AsyncStorage
* to persist the Nhost session on the device.
*
* This implementation synchronously works with the SessionStorageBackend interface
* while ensuring reliable persistence with AsyncStorage for Expo Go.
*/
export default class NhostAsyncStorage implements SessionStorageBackend {
private key: string;
private cache: Session | null = null;
constructor(key: string = DEFAULT_SESSION_KEY) {
this.key = key;
// Immediately try to load from AsyncStorage
this.loadFromAsyncStorage();
}
/**
* Load the session from AsyncStorage synchronously if possible
*/
private loadFromAsyncStorage(): void {
// Try to get cached data from AsyncStorage immediately
try {
AsyncStorage.getItem(this.key)
.then((value) => {
if (value) {
try {
this.cache = JSON.parse(value) as Session;
} catch (error) {
console.warn("Error parsing session from AsyncStorage:", error);
this.cache = null;
}
}
})
.catch((error) => {
console.warn("Error loading from AsyncStorage:", error);
});
} catch (error) {
console.warn("AsyncStorage access error:", error);
}
}
/**
* Gets the session from the in-memory cache
*/
get(): Session | null {
return this.cache;
}
/**
* Sets the session in the in-memory cache and persists to AsyncStorage
* Ensures the data gets written by using an immediately invoked async function
*/
set(value: Session): void {
// Update cache immediately
this.cache = value;
// Persist to AsyncStorage with better error handling
void (async () => {
try {
await AsyncStorage.setItem(this.key, JSON.stringify(value));
} catch (error) {
console.warn("Error saving session to AsyncStorage:", error);
}
})();
}
/**
* Removes the session from the in-memory cache and AsyncStorage
* Ensures the data gets removed by using an immediately invoked async function
*/
remove(): void {
// Clear cache immediately
this.cache = null;
// Remove from AsyncStorage with better error handling
void (async () => {
try {
await AsyncStorage.removeItem(this.key);
} catch (error) {
console.warn("Error removing session from AsyncStorage:", error);
}
})();
}
}

View File

@@ -0,0 +1,110 @@
import { createClient, type NhostClient } from "@nhost/nhost-js";
import type { Session } from "@nhost/nhost-js/session";
import Constants from "expo-constants";
import {
createContext,
type ReactNode,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import NhostAsyncStorage from "./AsyncStorage";
interface AuthContextType {
user: Session["user"] | null;
session: Session | null;
isAuthenticated: boolean;
isLoading: boolean;
nhost: NhostClient;
}
// Create context for authentication state and nhost client
const AuthContext = createContext<AuthContextType | null>(null);
interface AuthProviderProps {
children: ReactNode;
}
export const AuthProvider = ({ children }: AuthProviderProps) => {
const [user, setUser] = useState<Session["user"] | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
// Create the nhost client with persistent storage
const nhost = useMemo(() => {
// Get configuration values with type assertion
const subdomain =
(Constants.expoConfig?.extra?.["NHOST_SUBDOMAIN"] as string) ||
"192-168-1-103";
const region =
(Constants.expoConfig?.extra?.["NHOST_REGION"] as string) || "local";
return createClient({
subdomain,
region,
storage: new NhostAsyncStorage(),
});
}, []);
useEffect(() => {
// Initialize authentication state
setIsLoading(true);
// Allow enough time for AsyncStorage to be read and session to be restored
const initializeSession = async () => {
try {
// Let's wait a bit to ensure AsyncStorage has been read
await new Promise((resolve) => setTimeout(resolve, 100));
// Now try to get the current session
const currentSession = nhost.getUserSession();
setUser(currentSession?.user || null);
setSession(currentSession);
setIsAuthenticated(!!currentSession);
} catch (error) {
console.warn("Error initializing session:", error);
} finally {
setIsLoading(false);
}
};
void initializeSession();
// Listen for session changes
const unsubscribe = nhost.sessionStorage.onChange((currentSession) => {
setUser(currentSession?.user || null);
setSession(currentSession);
setIsAuthenticated(!!currentSession);
});
// Clean up subscription on unmount
return () => {
unsubscribe();
};
}, [nhost]);
// Context value with nhost client directly exposed
const value: AuthContextType = {
user,
session,
isAuthenticated,
isLoading,
nhost,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
// Custom hook to use the auth context
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
};
export default AuthProvider;

View File

@@ -0,0 +1,44 @@
/**
* Formats a file size in bytes to a human-readable string
* @param bytes File size in bytes
* @param decimals Number of decimal places to show
* @returns Formatted file size string (e.g., "1.23 MB")
*/
export function formatFileSize(bytes: number, decimals = 2): string {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
}
/**
* Default export to satisfy the Router's requirements
* This utilities file primarily exports helper functions
*/
export default {
formatFileSize,
};
/**
* Converts a Blob to a Base64 string
* @param blob The Blob object to convert
* @returns A Promise that resolves to the Base64 string representation of the Blob
*/
export function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const base64data = reader.result as string;
// Remove the data URL prefix (e.g., "data:application/octet-stream;base64,")
const base64Content = base64data.split(",")[1] || "";
resolve(base64Content);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}

View File

@@ -0,0 +1,262 @@
import { router } from "expo-router";
import { useEffect, useState } from "react";
import {
Alert,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import MFASettings from "./components/MFASettings";
import ProtectedScreen from "./components/ProtectedScreen";
import { useAuth } from "./lib/nhost/AuthProvider";
interface MfaStatusResponse {
user?: {
activeMfaType: string | null;
};
}
export default function Profile() {
const { nhost, user, session, isAuthenticated } = useAuth();
const [isMfaEnabled, setIsMfaEnabled] = useState<boolean>(false);
// Fetch MFA status when user is authenticated
useEffect(() => {
const fetchMfaStatus = async () => {
if (!user?.id) return;
try {
// Correctly structure GraphQL query with parameters
const response = await nhost.graphql.request<MfaStatusResponse>({
query: `
query GetUserMfaStatus($userId: uuid!) {
user(id: $userId) {
activeMfaType
}
}
`,
variables: {
userId: user.id,
},
});
const activeMfaType = response.body?.data?.user?.activeMfaType;
const newMfaEnabled = activeMfaType === "totp";
// Update the state
setIsMfaEnabled(newMfaEnabled);
} catch (err) {
const errMessage =
err instanceof Error ? err.message : "An unexpected error occurred";
console.error(`Failed to query MFA status: ${errMessage}`);
}
};
if (isAuthenticated && user?.id) {
void fetchMfaStatus();
}
}, [user, isAuthenticated, nhost.graphql]);
const handleSignOut = async () => {
try {
const session = nhost.getUserSession();
if (session) {
await nhost.auth.signOut({
refreshToken: session.refreshToken,
});
}
router.replace("/signin");
} catch {
Alert.alert("Error", "Failed to sign out");
}
};
return (
<ProtectedScreen>
<ScrollView
style={styles.container}
contentContainerStyle={styles.contentContainer}
>
<Text style={styles.title}>Your Profile</Text>
<View style={styles.card}>
<View style={styles.profileItem}>
<Text style={styles.itemLabel}>Display Name:</Text>
<Text style={styles.itemValue}>
{user?.displayName || "Not set"}
</Text>
</View>
<View style={styles.profileItem}>
<Text style={styles.itemLabel}>Email:</Text>
<Text style={styles.itemValue}>
{user?.email || "Not available"}
</Text>
</View>
<View style={styles.profileItem}>
<Text style={styles.itemLabel}>User ID:</Text>
<Text
style={styles.itemValue}
numberOfLines={1}
ellipsizeMode="middle"
>
{user?.id || "Not available"}
</Text>
</View>
<View style={styles.profileItem}>
<Text style={styles.itemLabel}>Roles:</Text>
<Text style={styles.itemValue}>
{user?.roles?.join(", ") || "None"}
</Text>
</View>
<View style={styles.profileItem}>
<Text style={styles.itemLabel}>Email Verified:</Text>
<Text style={styles.itemValue}>
{user?.emailVerified ? "Yes" : "No"}
</Text>
</View>
</View>
<View style={styles.card}>
<Text style={styles.sectionTitle}>Session Information</Text>
<View style={styles.sessionInfo}>
<Text style={styles.sessionText}>Refresh Token ID:</Text>
<Text
style={styles.sessionValue}
numberOfLines={1}
ellipsizeMode="middle"
>
{session?.refreshTokenId || "None"}
</Text>
<Text style={styles.sessionText}>Access Token Expires In:</Text>
<Text style={styles.sessionValue}>
{session?.accessTokenExpiresIn
? `${session.accessTokenExpiresIn}s`
: "N/A"}
</Text>
</View>
</View>
<MFASettings
key={`mfa-settings-${isMfaEnabled}`}
initialMfaEnabled={isMfaEnabled}
/>
<TouchableOpacity
style={styles.actionButton}
onPress={() => router.push("/upload")}
>
<Text style={styles.actionButtonText}>File Upload</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.signOutButton} onPress={handleSignOut}>
<Text style={styles.signOutButtonText}>Sign Out</Text>
</TouchableOpacity>
</ScrollView>
</ProtectedScreen>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#f5f5f5",
},
contentContainer: {
padding: 20,
paddingBottom: 40,
},
title: {
fontSize: 24,
fontWeight: "bold",
marginBottom: 20,
color: "#333",
textAlign: "center",
},
card: {
backgroundColor: "#fff",
borderRadius: 10,
padding: 16,
marginBottom: 20,
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 3.84,
elevation: 5,
},
profileItem: {
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: "#f0f0f0",
},
itemLabel: {
fontSize: 16,
fontWeight: "bold",
color: "#333",
marginBottom: 4,
},
itemValue: {
fontSize: 16,
color: "#666",
},
sectionTitle: {
fontSize: 18,
fontWeight: "bold",
marginBottom: 12,
color: "#333",
},
sessionInfo: {
backgroundColor: "#f8f8f8",
padding: 12,
borderRadius: 6,
},
sessionText: {
fontSize: 14,
fontWeight: "500",
color: "#333",
marginBottom: 2,
},
sessionValue: {
fontSize: 14,
color: "#666",
marginBottom: 10,
fontFamily: "monospace",
},
actionButton: {
backgroundColor: "#6366f1",
paddingVertical: 12,
paddingHorizontal: 20,
borderRadius: 8,
alignItems: "center",
marginTop: 10,
marginBottom: 10,
},
actionButtonText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
signOutButton: {
backgroundColor: "#e53e3e",
paddingVertical: 12,
paddingHorizontal: 20,
borderRadius: 8,
alignItems: "center",
marginTop: 10,
},
signOutButtonText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
});

View File

@@ -0,0 +1,367 @@
import { Link, router, useLocalSearchParams } from "expo-router";
import React, { useState } from "react";
import {
ActivityIndicator,
KeyboardAvoidingView,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import MagicLinkForm from "./components/MagicLinkForm";
import NativeLoginForm from "./components/NativeLoginForm";
import SocialLoginForm from "./components/SocialLoginForm";
import { useAuth } from "./lib/nhost/AuthProvider";
export default function SignIn() {
const { nhost, isAuthenticated } = useAuth();
const params = useLocalSearchParams();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [appleAuthInProgress, setAppleAuthInProgress] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<
"password" | "magic" | "social" | "native"
>("password");
const magicLinkSent = params["magic"] === "success";
// If already authenticated, redirect to profile
React.useEffect(() => {
if (isAuthenticated) {
router.replace("/profile");
}
}, [isAuthenticated]);
const handleSubmit = async () => {
setIsLoading(true);
setError(null);
try {
// Use the signIn function from auth context
const response = await nhost.auth.signInEmailPassword({
email,
password,
});
// Check if MFA is required
if (response.body?.mfa) {
router.push(`/signin/mfa?ticket=${response.body.mfa.ticket}`);
return;
}
// If we have a session, sign in was successful
if (response.body?.session) {
router.replace("/profile");
} else {
setError("Failed to sign in");
}
} catch (err) {
const errMessage =
err instanceof Error ? err.message : "An unexpected error occurred";
setError(`An error occurred during sign in: ${errMessage}`);
} finally {
setIsLoading(false);
}
};
return (
<KeyboardAvoidingView behavior="padding" style={styles.container}>
<ScrollView
contentContainerStyle={styles.scrollContainer}
keyboardShouldPersistTaps="handled"
>
<Text style={styles.title}>Nhost SDK Demo</Text>
<View style={styles.card}>
<Text style={styles.cardTitle}>Sign In</Text>
{magicLinkSent ? (
<View style={styles.messageContainer}>
<Text style={styles.successText}>
Magic link sent! Check your email to sign in.
</Text>
<TouchableOpacity
style={styles.secondaryButton}
onPress={() => router.setParams({ magic: "" })}
>
<Text style={styles.secondaryButtonText}>Back to sign in</Text>
</TouchableOpacity>
</View>
) : (
<>
<View style={styles.tabContainer}>
<TouchableOpacity
style={[
styles.tabButton,
activeTab === "password" && styles.activeTab,
]}
onPress={() => setActiveTab("password")}
>
<Text
style={[
styles.tabText,
activeTab === "password" && styles.activeTabText,
]}
>
Password
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.tabButton,
activeTab === "magic" && styles.activeTab,
]}
onPress={() => setActiveTab("magic")}
>
<Text
style={[
styles.tabText,
activeTab === "magic" && styles.activeTabText,
]}
>
Magic Link
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.tabButton,
activeTab === "social" && styles.activeTab,
]}
onPress={() => setActiveTab("social")}
>
<Text
style={[
styles.tabText,
activeTab === "social" && styles.activeTabText,
]}
>
Social
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.tabButton,
activeTab === "native" && styles.activeTab,
]}
onPress={() => setActiveTab("native")}
>
<Text
style={[
styles.tabText,
activeTab === "native" && styles.activeTabText,
]}
>
Native
</Text>
</TouchableOpacity>
</View>
<View style={styles.form}>
{activeTab === "password" ? (
<>
<View style={styles.inputGroup}>
<Text style={styles.label}>Email</Text>
<TextInput
style={styles.input}
value={email}
onChangeText={setEmail}
placeholder="Enter your email"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
/>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Password</Text>
<TextInput
style={styles.input}
value={password}
onChangeText={setPassword}
placeholder="Enter your password"
secureTextEntry
autoCapitalize="none"
/>
</View>
{error && <Text style={styles.errorText}>{error}</Text>}
<TouchableOpacity
style={styles.button}
onPress={handleSubmit}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.buttonText}>Sign In</Text>
)}
</TouchableOpacity>
</>
) : activeTab === "magic" ? (
<MagicLinkForm buttonLabel="Sign In with Magic Link" />
) : activeTab === "social" ? (
<SocialLoginForm action="Sign In" isLoading={isLoading} />
) : (
<NativeLoginForm
action="Sign In"
isLoading={isLoading || appleAuthInProgress}
setAppleAuthInProgress={setAppleAuthInProgress}
/>
)}
</View>
</>
)}
</View>
<View style={styles.footer}>
<Text style={styles.footerText}>
Don&apos;t have an account?{" "}
<Link href="/signup" style={styles.link}>
Sign Up
</Link>
</Text>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#f5f5f5",
},
scrollContainer: {
flexGrow: 1,
justifyContent: "center",
padding: 20,
},
title: {
fontSize: 24,
fontWeight: "bold",
textAlign: "center",
marginBottom: 20,
color: "#333",
},
card: {
width: "100%",
maxWidth: 400,
backgroundColor: "#fff",
borderRadius: 10,
padding: 20,
alignSelf: "center",
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 3.84,
elevation: 5,
},
cardTitle: {
fontSize: 20,
fontWeight: "bold",
marginBottom: 20,
textAlign: "center",
},
tabContainer: {
flexDirection: "row",
marginBottom: 20,
borderBottomWidth: 1,
borderBottomColor: "#e2e8f0",
},
tabButton: {
flex: 1,
paddingVertical: 10,
alignItems: "center",
},
tabText: {
fontSize: 16,
color: "#718096",
},
activeTab: {
borderBottomWidth: 2,
borderBottomColor: "#6366f1",
},
activeTabText: {
color: "#6366f1",
fontWeight: "600",
},
form: {
width: "100%",
},
inputGroup: {
marginBottom: 15,
},
label: {
fontSize: 16,
marginBottom: 5,
color: "#333",
},
input: {
height: 45,
borderWidth: 1,
borderColor: "#ddd",
borderRadius: 5,
paddingHorizontal: 10,
fontSize: 16,
backgroundColor: "#fafafa",
},
errorText: {
color: "#e53e3e",
marginBottom: 10,
},
successText: {
color: "#38a169",
fontSize: 16,
textAlign: "center",
marginBottom: 15,
},
messageContainer: {
alignItems: "center",
paddingVertical: 10,
},
button: {
backgroundColor: "#6366f1",
paddingVertical: 12,
borderRadius: 5,
alignItems: "center",
marginTop: 10,
},
buttonText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
secondaryButton: {
backgroundColor: "#e2e8f0",
paddingVertical: 12,
paddingHorizontal: 20,
borderRadius: 5,
alignItems: "center",
marginTop: 10,
},
secondaryButtonText: {
color: "#4a5568",
fontSize: 16,
fontWeight: "600",
},
footer: {
marginTop: 20,
alignItems: "center",
},
footerText: {
color: "#666",
fontSize: 14,
},
link: {
color: "#6366f1",
fontWeight: "bold",
},
});

View File

@@ -0,0 +1,233 @@
import { router, useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react";
import {
ActivityIndicator,
Alert,
Keyboard,
KeyboardAvoidingView,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
TouchableWithoutFeedback,
View,
} from "react-native";
import { useAuth } from "../lib/nhost/AuthProvider";
export default function MFAVerification() {
const { nhost } = useAuth();
const params = useLocalSearchParams();
const ticket = params["ticket"] as string;
const [verificationCode, setVerificationCode] = useState<string>("");
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
// Redirect if no ticket is provided
useEffect(() => {
if (!ticket) {
Alert.alert("Error", "Invalid authentication request");
router.replace("/signin");
}
}, [ticket]);
const handleSubmit = async () => {
if (!verificationCode || verificationCode.length !== 6) {
setError("Please enter a valid 6-digit code");
return;
}
if (!ticket) {
setError("Missing authentication ticket");
return;
}
setIsLoading(true);
setError(null);
try {
// Complete MFA verification
await nhost.auth.verifySignInMfaTotp({
ticket,
otp: verificationCode,
});
} catch (err) {
const errMessage =
err instanceof Error ? err.message : "An unexpected error occurred";
setError(`Verification failed: ${errMessage}`);
} finally {
setIsLoading(false);
}
};
return (
<KeyboardAvoidingView
behavior="padding"
style={styles.container}
keyboardVerticalOffset={40}
>
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<ScrollView
contentContainerStyle={styles.scrollViewContent}
keyboardShouldPersistTaps="handled"
>
<View style={styles.contentContainer}>
<Text style={styles.title}>Multi-Factor Authentication</Text>
<View style={styles.card}>
<Text style={styles.instructions}>
Enter the verification code from your authenticator app to
complete sign in.
</Text>
{error && (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{error}</Text>
</View>
)}
<View style={styles.inputContainer}>
<Text style={styles.label}>Authentication Code</Text>
<TextInput
style={styles.input}
value={verificationCode}
onChangeText={setVerificationCode}
placeholder="Enter 6-digit code"
keyboardType="number-pad"
maxLength={6}
autoFocus
returnKeyType="done"
onSubmitEditing={() => {
Keyboard.dismiss();
if (verificationCode.length === 6 && !isLoading) {
void handleSubmit();
}
}}
/>
</View>
<TouchableOpacity
style={[
styles.button,
(isLoading || verificationCode.length !== 6) &&
styles.buttonDisabled,
]}
onPress={handleSubmit}
disabled={isLoading || verificationCode.length !== 6}
>
{isLoading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.buttonText}>Verify</Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={styles.backLink}
onPress={() => router.back()}
disabled={isLoading}
>
<Text style={styles.backLinkText}>Back to Sign In</Text>
</TouchableOpacity>
</View>
</View>
</ScrollView>
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#f5f5f5",
},
scrollViewContent: {
flexGrow: 1,
},
contentContainer: {
flex: 1,
padding: 20,
justifyContent: "center",
paddingBottom: 40,
},
title: {
fontSize: 24,
fontWeight: "bold",
marginBottom: 20,
textAlign: "center",
color: "#333",
},
card: {
backgroundColor: "#fff",
borderRadius: 10,
padding: 20,
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 3.84,
elevation: 5,
},
instructions: {
fontSize: 16,
color: "#4b5563",
marginBottom: 20,
textAlign: "center",
},
errorContainer: {
backgroundColor: "#fee2e2",
padding: 12,
borderRadius: 6,
marginBottom: 16,
borderLeftWidth: 4,
borderLeftColor: "#ef4444",
},
errorText: {
color: "#b91c1c",
},
inputContainer: {
marginBottom: 20,
},
label: {
fontSize: 16,
marginBottom: 8,
color: "#374151",
},
input: {
borderWidth: 1,
borderColor: "#d1d5db",
borderRadius: 6,
padding: 12,
fontSize: 18,
backgroundColor: "#f9fafb",
textAlign: "center",
letterSpacing: 8,
},
button: {
backgroundColor: "#6366f1",
padding: 15,
borderRadius: 6,
alignItems: "center",
justifyContent: "center",
},
buttonDisabled: {
opacity: 0.5,
},
buttonText: {
color: "#fff",
fontWeight: "bold",
fontSize: 16,
},
backLink: {
marginTop: 20,
alignItems: "center",
},
backLinkText: {
color: "#6366f1",
fontSize: 16,
},
});

View File

@@ -0,0 +1,387 @@
import { Link, router, useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react";
import {
ActivityIndicator,
KeyboardAvoidingView,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import MagicLinkForm from "./components/MagicLinkForm";
import NativeLoginForm from "./components/NativeLoginForm";
import SocialLoginForm from "./components/SocialLoginForm";
import { useAuth } from "./lib/nhost/AuthProvider";
export default function SignUp() {
const { nhost, isAuthenticated } = useAuth();
const params = useLocalSearchParams();
const [email, setEmail] = useState<string>("");
const [password, setPassword] = useState<string>("");
const [displayName, setDisplayName] = useState<string>("");
const [isLoading, setIsLoading] = useState<boolean>(false);
const [appleAuthInProgress, setAppleAuthInProgress] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<
"password" | "magic" | "social" | "native"
>("password");
const magicLinkSent = params["magic"] === "success";
// If already authenticated, redirect to profile
useEffect(() => {
if (isAuthenticated) {
router.replace("/profile");
}
}, [isAuthenticated]);
const handleSubmit = async () => {
setIsLoading(true);
setError(null);
try {
const response = await nhost.auth.signUpEmailPassword({
email,
password,
options: {
displayName,
},
});
if (response.body?.session) {
// Successfully signed up and automatically signed in
router.replace("/profile");
} else {
// Verification email might be required
router.replace("/signin");
}
} catch (err) {
const errMessage =
err instanceof Error ? err.message : "An unexpected error occurred";
setError(`An error occurred during sign up: ${errMessage}`);
} finally {
setIsLoading(false);
}
};
// Social login is now handled by the SocialLoginForm component
return (
<KeyboardAvoidingView behavior="padding" style={styles.container}>
<ScrollView
contentContainerStyle={styles.scrollContainer}
keyboardShouldPersistTaps="handled"
>
<Text style={styles.title}>Nhost SDK Demo</Text>
<View style={styles.card}>
<Text style={styles.cardTitle}>Sign Up</Text>
{magicLinkSent ? (
<View style={styles.messageContainer}>
<Text style={styles.successText}>
Magic link sent! Check your email to sign in.
</Text>
<TouchableOpacity
style={styles.secondaryButton}
onPress={() => router.setParams({ magic: "" })}
>
<Text style={styles.secondaryButtonText}>Back to sign up</Text>
</TouchableOpacity>
</View>
) : (
<>
<View style={styles.tabContainer}>
<TouchableOpacity
style={[
styles.tabButton,
activeTab === "password" && styles.activeTab,
]}
onPress={() => setActiveTab("password")}
>
<Text
style={[
styles.tabText,
activeTab === "password" && styles.activeTabText,
]}
>
Password
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.tabButton,
activeTab === "magic" && styles.activeTab,
]}
onPress={() => setActiveTab("magic")}
>
<Text
style={[
styles.tabText,
activeTab === "magic" && styles.activeTabText,
]}
>
Magic Link
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.tabButton,
activeTab === "social" && styles.activeTab,
]}
onPress={() => setActiveTab("social")}
>
<Text
style={[
styles.tabText,
activeTab === "social" && styles.activeTabText,
]}
>
Social
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.tabButton,
activeTab === "native" && styles.activeTab,
]}
onPress={() => setActiveTab("native")}
>
<Text
style={[
styles.tabText,
activeTab === "native" && styles.activeTabText,
]}
>
Native
</Text>
</TouchableOpacity>
</View>
<View style={styles.form}>
{activeTab === "password" ? (
<>
<View style={styles.inputGroup}>
<Text style={styles.label}>Display Name</Text>
<TextInput
style={styles.input}
value={displayName}
onChangeText={setDisplayName}
placeholder="Enter your name"
autoCapitalize="words"
/>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Email</Text>
<TextInput
style={styles.input}
value={email}
onChangeText={setEmail}
placeholder="Enter your email"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
/>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Password</Text>
<TextInput
style={styles.input}
value={password}
onChangeText={setPassword}
placeholder="Enter your password"
secureTextEntry
autoCapitalize="none"
/>
<Text style={styles.helperText}>
Password must be at least 8 characters long
</Text>
</View>
{error && <Text style={styles.errorText}>{error}</Text>}
<TouchableOpacity
style={styles.button}
onPress={handleSubmit}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.buttonText}>Sign Up</Text>
)}
</TouchableOpacity>
</>
) : activeTab === "magic" ? (
<MagicLinkForm buttonLabel="Sign Up with Magic Link" />
) : activeTab === "social" ? (
<SocialLoginForm action="Sign Up" isLoading={isLoading} />
) : (
<NativeLoginForm
action="Sign Up"
isLoading={isLoading || appleAuthInProgress}
setAppleAuthInProgress={setAppleAuthInProgress}
/>
)}
</View>
</>
)}
</View>
<View style={styles.footer}>
<Text style={styles.footerText}>
Already have an account?{" "}
<Link href="/signin" style={styles.link}>
Sign In
</Link>
</Text>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#f5f5f5",
},
scrollContainer: {
flexGrow: 1,
justifyContent: "center",
padding: 20,
},
title: {
fontSize: 24,
fontWeight: "bold",
textAlign: "center",
marginBottom: 20,
color: "#333",
},
card: {
width: "100%",
maxWidth: 400,
backgroundColor: "#fff",
borderRadius: 10,
padding: 20,
alignSelf: "center",
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 3.84,
elevation: 5,
},
cardTitle: {
fontSize: 20,
fontWeight: "bold",
marginBottom: 20,
textAlign: "center",
},
tabContainer: {
flexDirection: "row",
marginBottom: 20,
borderBottomWidth: 1,
borderBottomColor: "#e2e8f0",
},
tabButton: {
flex: 1,
paddingVertical: 10,
alignItems: "center",
},
tabText: {
fontSize: 16,
color: "#718096",
},
activeTab: {
borderBottomWidth: 2,
borderBottomColor: "#6366f1",
},
activeTabText: {
color: "#6366f1",
fontWeight: "600",
},
form: {
width: "100%",
},
inputGroup: {
marginBottom: 15,
},
label: {
fontSize: 16,
marginBottom: 5,
color: "#333",
},
input: {
height: 45,
borderWidth: 1,
borderColor: "#ddd",
borderRadius: 5,
paddingHorizontal: 10,
fontSize: 16,
backgroundColor: "#fafafa",
},
helperText: {
fontSize: 12,
color: "#666",
marginTop: 3,
},
errorText: {
color: "#e53e3e",
marginBottom: 10,
},
successText: {
color: "#38a169",
fontSize: 16,
textAlign: "center",
marginBottom: 15,
},
messageContainer: {
alignItems: "center",
paddingVertical: 10,
},
button: {
backgroundColor: "#6366f1",
paddingVertical: 12,
borderRadius: 5,
alignItems: "center",
marginTop: 10,
},
buttonText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
secondaryButton: {
backgroundColor: "#e2e8f0",
paddingVertical: 12,
paddingHorizontal: 20,
borderRadius: 5,
alignItems: "center",
marginTop: 10,
},
secondaryButtonText: {
color: "#4a5568",
fontSize: 16,
fontWeight: "600",
},
footer: {
marginTop: 20,
alignItems: "center",
},
footerText: {
color: "#666",
fontSize: 14,
},
link: {
color: "#6366f1",
fontWeight: "bold",
},
});

View File

@@ -0,0 +1,552 @@
import type { FetchError } from "@nhost/nhost-js/fetch";
import type { ErrorResponse, FileMetadata } from "@nhost/nhost-js/storage";
import * as DocumentPicker from "expo-document-picker";
import * as FileSystem from "expo-file-system";
import { Stack } from "expo-router";
import * as Sharing from "expo-sharing";
import { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator,
Alert,
FlatList,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import ProtectedScreen from "./components/ProtectedScreen";
import { useAuth } from "./lib/nhost/AuthProvider";
import { blobToBase64, formatFileSize } from "./lib/utils";
interface DeleteStatus {
message: string;
isError: boolean;
}
interface GraphqlGetFilesResponse {
files: FileMetadata[];
}
export default function Upload() {
const { nhost } = useAuth();
const [selectedFile, setSelectedFile] =
useState<DocumentPicker.DocumentPickerResult | null>(null);
const [uploading, setUploading] = useState<boolean>(false);
const [uploadResult, setUploadResult] = useState<FileMetadata | null>(null);
const [isFetching, setIsFetching] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [files, setFiles] = useState<FileMetadata[]>([]);
const [viewingFile, setViewingFile] = useState<string | null>(null);
const [deleting, setDeleting] = useState<string | null>(null);
const [deleteStatus, setDeleteStatus] = useState<DeleteStatus | null>(null);
const fetchFiles = useCallback(async () => {
setIsFetching(true);
setError(null);
try {
// Fetch files using GraphQL query
const response = await nhost.graphql.request<GraphqlGetFilesResponse>({
query: `query GetFiles {
files {
id
name
size
mimeType
bucketId
uploadedByUserId
}
}`,
});
setFiles(response.body.data?.files || []);
} catch (err) {
const errMessage =
err instanceof Error ? err.message : "An unexpected error occurred";
setError(`Failed to fetch files: ${errMessage}`);
} finally {
setIsFetching(false);
}
}, [nhost.graphql]);
// Fetch existing files when component mounts
useEffect(() => {
void fetchFiles();
}, [fetchFiles]);
const pickDocument = async () => {
try {
const result = await DocumentPicker.getDocumentAsync({
type: "*/*", // All file types
copyToCacheDirectory: true,
});
if (!result.canceled) {
setSelectedFile(result);
setError(null);
setUploadResult(null);
}
} catch (err) {
setError("Failed to pick document");
console.error("DocumentPicker Error:", err);
}
};
const handleUpload = async () => {
if (!selectedFile || selectedFile.canceled) {
setError("Please select a file to upload");
return;
}
setUploading(true);
setError(null);
try {
// For React Native, we need to read the file first
const fileToUpload = selectedFile.assets?.[0];
if (!fileToUpload) {
throw new Error("No file selected");
}
const file: unknown = {
uri: fileToUpload.uri,
name: fileToUpload.name || "file",
type: fileToUpload.mimeType || "application/octet-stream",
};
// Upload file using Nhost storage
const response = await nhost.storage.uploadFiles({
"bucket-id": "default",
"file[]": [file as File],
});
// Get the processed file data
const uploadedFile = response.body.processedFiles?.[0];
if (uploadedFile === undefined) {
throw new Error("Failed to upload file");
}
setUploadResult(uploadedFile);
// Reset form
setSelectedFile(null);
// Update files list
setFiles((prevFiles) => [uploadedFile, ...prevFiles]);
// Refresh file list
await fetchFiles();
// Clear success message after 3 seconds
setTimeout(() => {
setUploadResult(null);
}, 3000);
} catch (err: unknown) {
const error = err as FetchError<ErrorResponse>;
setError(`Failed to upload file: ${error.message}`);
console.error("Upload error:", err);
} finally {
setUploading(false);
}
};
// Function to handle viewing a file with proper authorization
const handleViewFile = async (
fileId: string,
fileName: string,
mimeType: string,
) => {
setViewingFile(fileId);
try {
// Fetch the file with authentication using the SDK
const response = await nhost.storage.getFile(fileId);
if (!response.body) {
throw new Error("Failed to retrieve file contents");
}
// For iOS/Android, we need to save the file to the device first
// Create a unique temp file path with a timestamp to prevent collisions
const fileExtension = fileName.includes(".") ? "" : ".file";
const tempFileName = fileName.includes(".")
? fileName
: `${fileName}${fileExtension}`;
const tempFilePath = `${FileSystem.cacheDirectory}${Date.now()}_${tempFileName}`;
// Get the blob from the response
const blob = response.body;
// Convert blob to base64
const base64Data = await blobToBase64(blob);
// Write the file to the filesystem
await FileSystem.writeAsStringAsync(tempFilePath, base64Data, {
encoding: FileSystem.EncodingType.Base64,
});
// Check if sharing is available (iOS & Android)
const isSharingAvailable = await Sharing.isAvailableAsync();
if (isSharingAvailable) {
// Open the file with the default app
await Sharing.shareAsync(tempFilePath, {
mimeType: mimeType || "application/octet-stream",
dialogTitle: `View ${fileName}`,
UTI: mimeType, // for iOS
});
} else {
throw new Error("Sharing is not available on this device");
}
} catch (err) {
const error = err as FetchError<ErrorResponse>;
setError(`Failed to view file: ${error.message}`);
console.error("Error viewing file:", err);
Alert.alert("Error", `Failed to view file: ${error.message}`);
} finally {
setViewingFile(null);
}
};
// Function to handle deleting a file
const handleDeleteFile = (fileId: string) => {
if (!fileId || deleting) return;
// Confirm deletion
Alert.alert("Delete File", "Are you sure you want to delete this file?", [
{
text: "Cancel",
style: "cancel",
},
{
text: "Delete",
style: "destructive",
onPress: () => {
void (async () => {
setDeleting(fileId);
setError(null);
setDeleteStatus(null);
// Get the file name for the status message
const fileToDelete = files.find((file) => file.id === fileId);
const fileName = fileToDelete?.name || "File";
try {
// Delete the file using the Nhost storage SDK
await nhost.storage.deleteFile(fileId);
// Show success message
setDeleteStatus({
message: `${fileName} deleted successfully`,
isError: false,
});
// Update the local files list by removing the deleted file
setFiles(files.filter((file) => file.id !== fileId));
// Refresh the file list
await fetchFiles();
// Clear the success message after 3 seconds
setTimeout(() => {
setDeleteStatus(null);
}, 3000);
} catch (err) {
// Show error message
const error = err as FetchError<ErrorResponse>;
setDeleteStatus({
message: `Failed to delete ${fileName}: ${error.message}`,
isError: true,
});
console.error("Error deleting file:", err);
} finally {
setDeleting(null);
}
})();
},
},
]);
};
return (
<ProtectedScreen>
<Stack.Screen options={{ title: "File Upload" }} />
<View style={styles.container}>
{/* Upload Form */}
<View style={styles.card}>
<Text style={styles.title}>Upload a File</Text>
<TouchableOpacity style={styles.fileUpload} onPress={pickDocument}>
<View style={styles.uploadIcon}>
<Text style={styles.uploadIconText}></Text>
</View>
<Text style={styles.uploadText}>Tap to select a file</Text>
{selectedFile &&
!selectedFile.canceled &&
selectedFile.assets?.[0] && (
<Text style={styles.fileName}>
{selectedFile.assets[0].name}(
{formatFileSize(selectedFile.assets[0].size || 0)})
</Text>
)}
</TouchableOpacity>
{error && (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{error}</Text>
</View>
)}
{uploadResult && (
<View style={styles.successContainer}>
<Text style={styles.successText}>
File uploaded successfully!
</Text>
</View>
)}
<TouchableOpacity
style={[
styles.button,
(!selectedFile || selectedFile.canceled || uploading) &&
styles.buttonDisabled,
]}
onPress={handleUpload}
disabled={!selectedFile || selectedFile.canceled || uploading}
>
<Text style={styles.buttonText}>
{uploading ? "Uploading..." : "Upload File"}
</Text>
</TouchableOpacity>
</View>
{/* Files List */}
<View style={styles.card}>
<Text style={styles.title}>Your Files</Text>
{deleteStatus && (
<View
style={[
styles.statusContainer,
deleteStatus.isError
? styles.errorContainer
: styles.successContainer,
]}
>
<Text
style={
deleteStatus.isError ? styles.errorText : styles.successText
}
>
{deleteStatus.message}
</Text>
</View>
)}
{isFetching ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#0000ff" />
<Text style={styles.loadingText}>Loading files...</Text>
</View>
) : files.length === 0 ? (
<Text style={styles.emptyText}>No files uploaded yet.</Text>
) : (
<FlatList
data={files}
keyExtractor={(item) => item.id || Math.random().toString()}
renderItem={({ item }) => (
<View style={styles.fileItem}>
<View style={styles.fileInfo}>
<Text style={styles.fileNameText} numberOfLines={1}>
{item.name}
</Text>
<Text style={styles.fileDetails}>
{item.mimeType} {formatFileSize(item.size || 0)}
</Text>
</View>
<View style={styles.fileActions}>
<TouchableOpacity
style={styles.actionButton}
onPress={() =>
handleViewFile(
item.id || "unknown",
item.name || "unknown",
item.mimeType || "unknown",
)
}
disabled={viewingFile === item.id}
>
{viewingFile === item.id ? (
<Text style={styles.actionText}></Text>
) : (
<Text style={styles.actionText}>👁</Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.deleteButton]}
onPress={() => handleDeleteFile(item.id || "unknown")}
disabled={deleting === item.id}
>
{deleting === item.id ? (
<Text style={styles.actionText}></Text>
) : (
<Text style={styles.actionText}>🗑</Text>
)}
</TouchableOpacity>
</View>
</View>
)}
style={styles.fileList}
/>
)}
</View>
</View>
</ProtectedScreen>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 16,
backgroundColor: "#f5f5f5",
},
card: {
backgroundColor: "#fff",
borderRadius: 10,
padding: 20,
marginBottom: 20,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
title: {
fontSize: 20,
fontWeight: "bold",
marginBottom: 16,
color: "#333",
},
fileUpload: {
borderWidth: 2,
borderColor: "#ddd",
borderStyle: "dashed",
borderRadius: 10,
padding: 20,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#f9f9f9",
marginBottom: 16,
},
uploadIcon: {
marginBottom: 10,
},
uploadIconText: {
fontSize: 24,
},
uploadText: {
fontSize: 16,
color: "#666",
},
fileName: {
marginTop: 8,
color: "#0066cc",
fontSize: 14,
},
button: {
backgroundColor: "#0066cc",
padding: 15,
borderRadius: 8,
alignItems: "center",
},
buttonDisabled: {
backgroundColor: "#ccc",
},
buttonText: {
color: "white",
fontWeight: "bold",
fontSize: 16,
},
errorContainer: {
backgroundColor: "#ffebee",
padding: 10,
borderRadius: 8,
marginBottom: 16,
borderLeftWidth: 4,
borderLeftColor: "#f44336",
},
errorText: {
color: "#d32f2f",
},
successContainer: {
backgroundColor: "#e8f5e9",
padding: 10,
borderRadius: 8,
marginBottom: 16,
borderLeftWidth: 4,
borderLeftColor: "#4caf50",
},
successText: {
color: "#2e7d32",
},
statusContainer: {
padding: 10,
borderRadius: 8,
marginBottom: 16,
borderLeftWidth: 4,
},
loadingContainer: {
alignItems: "center",
padding: 20,
},
loadingText: {
marginTop: 10,
color: "#666",
},
emptyText: {
textAlign: "center",
color: "#666",
padding: 20,
},
fileList: {
maxHeight: 300,
},
fileItem: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: 12,
paddingHorizontal: 8,
borderBottomWidth: 1,
borderBottomColor: "#eee",
},
fileInfo: {
flex: 1,
paddingRight: 10,
},
fileNameText: {
fontSize: 16,
fontWeight: "500",
color: "#333",
marginBottom: 4,
},
fileDetails: {
fontSize: 12,
color: "#777",
},
fileActions: {
flexDirection: "row",
},
actionButton: {
padding: 8,
marginHorizontal: 4,
borderRadius: 20,
backgroundColor: "#f0f0f0",
},
deleteButton: {
backgroundColor: "#fff0f0",
},
actionText: {
fontSize: 16,
},
});

View File

@@ -0,0 +1,265 @@
import { router, useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react";
import {
ActivityIndicator,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { useAuth } from "./lib/nhost/AuthProvider";
export default function Verify() {
const params = useLocalSearchParams<{ refreshToken: string }>();
const [status, setStatus] = useState<"verifying" | "success" | "error">(
"verifying",
);
const [error, setError] = useState<string>("");
const { nhost, isAuthenticated } = useAuth();
useEffect(() => {
const refreshToken = params.refreshToken;
if (!refreshToken) {
setStatus("error");
setError("No refresh token found in the link");
return;
}
// Flag to handle component unmounting during async operations
let isMounted = true;
async function processToken(): Promise<void> {
try {
// First display the verifying message for at least a moment
await new Promise((resolve) => setTimeout(resolve, 500));
if (!isMounted) return;
if (!refreshToken) {
// Collect all URL parameters to display
const allParams: Record<string, string> = {};
Object.entries(params).forEach(([key, value]) => {
if (typeof value === "string") {
allParams[key] = value;
}
});
setStatus("error");
setError("No refresh token found in the link");
return;
}
// Process the token
await nhost.auth.refreshToken({ refreshToken });
if (!isMounted) return;
setStatus("success");
// Wait to show success message briefly, then redirect
setTimeout(() => {
if (isMounted) router.replace("/profile");
}, 1500);
} catch (err) {
if (!isMounted) return;
const errMessage =
err instanceof Error ? err.message : "An unexpected error occurred";
setStatus("error");
setError(`An error occurred during verification: ${errMessage}`);
}
}
void processToken();
// Cleanup function
return () => {
isMounted = false;
};
}, [params, nhost.auth]);
// If already authenticated and not handling verification, redirect to profile
useEffect(() => {
if (isAuthenticated && status !== "verifying") {
router.replace("/profile");
}
}, [isAuthenticated, status]);
return (
<View style={styles.container}>
<Text style={styles.title}>Nhost SDK Demo</Text>
<View style={styles.card}>
<Text style={styles.cardTitle}>Email Verification</Text>
<View style={styles.contentContainer}>
{status === "verifying" && (
<View>
<Text style={styles.statusText}>Verifying your email...</Text>
<ActivityIndicator
size="large"
color="#6366f1"
style={styles.spinner}
/>
</View>
)}
{status === "success" && (
<View>
<Text style={styles.successText}> Successfully verified!</Text>
<Text style={styles.statusText}>
You&apos;ll be redirected to your profile page shortly...
</Text>
</View>
)}
{status === "error" && (
<View>
<Text style={styles.errorText}>Verification failed</Text>
<Text style={styles.statusText}>{error}</Text>
<View style={styles.debugInfo}>
<Text style={styles.debugTitle}>Testing in Expo Go?</Text>
<Text style={styles.debugText}>
Make sure your magic link uses the proper Expo Go format.
</Text>
</View>
<TouchableOpacity
onPress={() => router.replace("/signin")}
style={styles.button}
>
<Text style={styles.buttonText}>Back to Sign In</Text>
</TouchableOpacity>
</View>
)}
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
debugInfo: {
backgroundColor: "#fff8dc",
padding: 10,
borderRadius: 5,
marginVertical: 10,
borderWidth: 1,
borderColor: "#ffd700",
},
debugTitle: {
fontWeight: "bold",
marginBottom: 5,
color: "#b8860b",
},
debugText: {
color: "#5a4a00",
fontSize: 14,
},
container: {
flex: 1,
backgroundColor: "#f5f5f5",
justifyContent: "center",
padding: 20,
},
title: {
fontSize: 24,
fontWeight: "bold",
textAlign: "center",
marginBottom: 20,
color: "#333",
},
card: {
width: "100%",
maxWidth: 400,
backgroundColor: "#fff",
borderRadius: 10,
padding: 20,
alignSelf: "center",
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 3.84,
elevation: 5,
},
cardTitle: {
fontSize: 20,
fontWeight: "bold",
marginBottom: 20,
textAlign: "center",
},
contentContainer: {
alignItems: "center",
paddingVertical: 20,
},
statusText: {
fontSize: 16,
textAlign: "center",
marginBottom: 15,
color: "#4a5568",
},
spinner: {
marginVertical: 20,
},
successText: {
color: "#38a169",
fontSize: 18,
fontWeight: "bold",
textAlign: "center",
marginBottom: 10,
},
errorText: {
color: "#e53e3e",
fontSize: 18,
fontWeight: "bold",
textAlign: "center",
marginBottom: 10,
},
paramsContainer: {
backgroundColor: "#f7fafc",
borderRadius: 5,
padding: 10,
marginVertical: 15,
width: "100%",
maxHeight: 150,
},
paramsTitle: {
fontWeight: "bold",
marginBottom: 5,
color: "#2d3748",
},
paramRow: {
flexDirection: "row",
marginBottom: 5,
},
paramKey: {
color: "#4299e1",
marginRight: 5,
fontFamily: "monospace",
},
paramValue: {
flex: 1,
fontFamily: "monospace",
color: "#2d3748",
},
button: {
backgroundColor: "#6366f1",
paddingVertical: 12,
borderRadius: 5,
alignItems: "center",
marginTop: 15,
width: "100%",
},
buttonText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,58 @@
{
"name": "reactnativewebdemo",
"main": "expo-router/entry",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"generate": "echo 'Nothing to do'",
"test": "pnpm test:typecheck && pnpm test:lint",
"test:typecheck": "tsc --noEmit",
"test:lint": "biome check",
"format": "biome format --write",
"build": "pnpm expo export -p ios -p android",
"android": "expo start --android",
"ios": "expo start --ios"
},
"dependencies": {
"@expo/vector-icons": "^14.1.0",
"@nhost/nhost-js": "workspace:*",
"@react-native-async-storage/async-storage": "^2.1.2",
"@react-navigation/bottom-tabs": "^7.3.14",
"@react-navigation/elements": "^2.4.3",
"@react-navigation/native": "^7.1.10",
"expo": "~53.0.10",
"expo-apple-authentication": "~7.2.4",
"expo-blur": "~14.1.5",
"expo-clipboard": "^7.1.4",
"expo-constants": "~17.1.6",
"expo-crypto": "~14.1.4",
"expo-document-picker": "^13.1.5",
"expo-file-system": "^18.1.10",
"expo-font": "~13.3.1",
"expo-haptics": "~14.1.4",
"expo-image": "~2.2.0",
"expo-linking": "~7.1.5",
"expo-router": "~5.0.7",
"expo-sharing": "^13.1.5",
"expo-splash-screen": "~0.30.9",
"expo-status-bar": "~2.2.3",
"expo-symbols": "~0.4.5",
"expo-system-ui": "~5.0.8",
"expo-web-browser": "~14.1.6",
"react": "19.0.0",
"react-native": "0.79.3",
"react-native-gesture-handler": "~2.24.0",
"react-native-reanimated": "~3.17.5",
"react-native-safe-area-context": "5.4.0",
"react-native-screens": "^4.11.1",
"react-native-webview": "13.13.5"
},
"devDependencies": {
"@babel/core": "^7.27.4",
"@types/react": "~19.0.14",
"@types/node": "^22.15.17"
},
"private": true
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
{
"extends": [
"expo/tsconfig.base",
"../../../build/configs/tsconfig/base.json"
],
"compilerOptions": {
"paths": {
"@/*": ["./*"]
},
/* Override specific settings from base.json as needed for React Native */
"lib": ["ESNext"],
"jsx": "react-native"
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
}

2
examples/demos/backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.nhost
.secrets

View File

@@ -0,0 +1,16 @@
GRAFANA_ADMIN_PASSWORD = 'grafana-admin-password'
HASURA_GRAPHQL_ADMIN_SECRET = 'nhost-admin-secret'
HASURA_GRAPHQL_JWT_SECRET = '55b1d038dff8d4f9a440e848250668527fa5b563700be0dc39e356f1c91f867e'
NHOST_WEBHOOK_SECRET = 'nhost-webhook-secret'
GITHUB_CLIENT_ID='fixme'
GITHUB_CLIENT_SECRET='fixme'
APPLE_TEAM_ID='fakeTeamId'
APPLE_CLIENT_ID='host.exp.Exponent'
APPLE_AUDIENCE='host.exp.Exponent'
APPLE_KEY_ID='fakeKeyId'
APPLE_PRIVATE_KEY='''-----BEGIN PRIVATE KEY-----
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQglHTWHjauHnKCxjEP
BpMYsTDI2cihQi4tAYHTthj+FF+gCgYIKoZIzj0DAQehRANCAAR30Hs8vTbED10z
Qx2m4sJu+lE/ZJsRvDkqLqYF8uh1Tb1g7/KKr8Y7qkK3DmCg72bCyirEq4NVUi2r
M/6TYMpw
-----END PRIVATE KEY-----'''

View File

@@ -0,0 +1,7 @@
.PHONY: dev-env-up
dev-env-up:
@./env-up.sh
.PHONY: dev-env-down
dev-env-down:
@nhost down --volumes

View File

@@ -0,0 +1,29 @@
# backend
This is a very simple Nhost backend that we will use to demonstrate how to use the various SDKs we are experimenting with. The backend will consist of the following:
## Database schema
- A `tasks` table with the following columns:
- `id` (UUID)
- `created_at` (Timestamp)
- `updated_at` (Timestamp)
- `user_id` (foreigh key to `auth.users.id`)
- `title` (Text)
- `description` (Text)
- `completed` (Boolean)
- An `attachments` table with the following columns:
- `task_id` (foreign key to `tasks.id`)
- `file_id` (foreign key to `storage.files.id`)
Permissions:
- `tasks`: the `user` role can insert/select/update tasks that they own. Ownership is tracked by the `user_id` column which is set automatically on insert from the session.
- `attachments`: the `user` role can insert/select/delete attachments for tasks and files that they own
- `storage.files`: the `user` role can insert/select/delete files that they own
## Functions
- A `simple` function called `echo` that will just return back some request information

View File

@@ -0,0 +1,8 @@
#!/bin/sh
# if .secrets file doesn't exist, cp .secrets.example .secrets
if [ ! -f .secrets ]; then
cp .secrets.example .secrets
fi
nhost up

View File

@@ -0,0 +1,14 @@
{
"name": "functions",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "functions",
"version": "1.0.0",
"license": "ISC",
"devDependencies": {}
}
}
}

View File

@@ -0,0 +1,13 @@
{
"name": "functions",
"version": "1.0.0",
"description": "",
"main": "index.js",
"devDependencies": {},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"allowJs": true,
"skipLibCheck": true,
"noEmit": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"strictNullChecks": false
}
}

View File

@@ -0,0 +1 @@
version: 3

View File

@@ -0,0 +1,52 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
<tbody>
<tr style="width: 100%">
<td>
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Потвърдете смяната на вашия имейл</h1>
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Използвайте посочения линк, за да повърдите смяната на имейл:</p>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
<tbody>
<tr>
<td>
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Смени имейл</span
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span
></a
>
</td>
</tr>
</tbody>
</table>
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody style="width: 100%">
<tr style="width: 100%">
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1 @@
Потвърждение за смяна на имейл

View File

@@ -0,0 +1,52 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
<tbody>
<tr style="width: 100%">
<td>
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Потвърдете вашия имейл</h1>
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Използвайте посочения линк, за да потвърдите вашия имейл:</p>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
<tbody>
<tr>
<td>
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Потвърдете имейл</span
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span
></a
>
</td>
</tr>
</tbody>
</table>
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody style="width: 100%">
<tr style="width: 100%">
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1 @@
Потвърждаване на имейл

View File

@@ -0,0 +1,52 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
<tbody>
<tr style="width: 100%">
<td>
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Смяна на парола</h1>
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Използвайте посочения линк, за да смените вашата парола:</p>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
<tbody>
<tr>
<td>
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Смяна на парола</span
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span
></a
>
</td>
</tr>
</tbody>
</table>
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody style="width: 100%">
<tr style="width: 100%">
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1 @@
Смяна на парола

View File

@@ -0,0 +1,43 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
<tbody>
<tr style="width: 100%">
<td>
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">One-time Password</h1>
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">За да влезете в ${redirectTo}, моля, използвайте следната еднократна парола:</p>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
<tbody>
<tr>
<td><p style="font-size: 24px; line-height: 32px; margin: 16px 0; color: #0052cd; font-weight: 600">${ticket}</p></td>
</tr>
</tbody>
</table>
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody style="width: 100%">
<tr style="width: 100%">
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1 @@
Еднократна парола за ${redirectTo}

View File

@@ -0,0 +1 @@
Вашият код е ${code}.

View File

@@ -0,0 +1,52 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
<tbody>
<tr style="width: 100%">
<td>
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Магически линк за вход</h1>
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Използвайте посочения линк за защитен и бърз вход:</p>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
<tbody>
<tr>
<td>
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Вход</span
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span
></a
>
</td>
</tr>
</tbody>
</table>
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody style="width: 100%">
<tr style="width: 100%">
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1 @@
Магически линк за вход

View File

@@ -0,0 +1,52 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
<tbody>
<tr style="width: 100%">
<td>
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Potvrzení změny emailové adresy</h1>
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Použijte tento odkaz k potvrzení změny emailové adresy:</p>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
<tbody>
<tr>
<td>
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Změnit email</span
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span
></a
>
</td>
</tr>
</tbody>
</table>
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody style="width: 100%">
<tr style="width: 100%">
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1 @@
Změna vaší emailové adresy

View File

@@ -0,0 +1,52 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
<tbody>
<tr style="width: 100%">
<td>
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Ověření emailové adresy</h1>
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Použijte tento odkaz k ověření vaší emailové adresy:</p>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
<tbody>
<tr>
<td>
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Ověřit emailovou adresu</span
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span
></a
>
</td>
</tr>
</tbody>
</table>
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody style="width: 100%">
<tr style="width: 100%">
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1 @@
Ověření vaší emailové adresy

View File

@@ -0,0 +1,52 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
<tbody>
<tr style="width: 100%">
<td>
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Obnova hesla</h1>
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Použijte tento odkaz k obnovení vašeho hesla:</p>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
<tbody>
<tr>
<td>
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Obnova hesla</span
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span
></a
>
</td>
</tr>
</tbody>
</table>
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody style="width: 100%">
<tr style="width: 100%">
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1 @@
Obnova hesla

View File

@@ -0,0 +1,43 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
<tbody>
<tr style="width: 100%">
<td>
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">One-time Password</h1>
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Pro přihlášení do ${redirectTo}, prosím, použijte následující jednorázové heslo:</p>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
<tbody>
<tr>
<td><p style="font-size: 24px; line-height: 32px; margin: 16px 0; color: #0052cd; font-weight: 600">${ticket}</p></td>
</tr>
</tbody>
</table>
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody style="width: 100%">
<tr style="width: 100%">
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1 @@
Jednorázové heslo pro ${redirectTo}

View File

@@ -0,0 +1 @@
Váš kód je ${code}.

View File

@@ -0,0 +1,52 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
<tbody>
<tr style="width: 100%">
<td>
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Magický odkaz</h1>
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Použijte tento odkaz k bezpečnému přihlášení:</p>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
<tbody>
<tr>
<td>
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Přihlášení</span
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span
></a
>
</td>
</tr>
</tbody>
</table>
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody style="width: 100%">
<tr style="width: 100%">
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1 @@
Bezpečný odkaz k přihlášení

View File

@@ -0,0 +1,52 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
<tbody>
<tr style="width: 100%">
<td>
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Confirm Email Change</h1>
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Use this link to confirm changing email:</p>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
<tbody>
<tr>
<td>
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Change Email</span
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span
></a
>
</td>
</tr>
</tbody>
</table>
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody style="width: 100%">
<tr style="width: 100%">
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1 @@
Change your email address

View File

@@ -0,0 +1,52 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
<tbody>
<tr style="width: 100%">
<td>
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Verify Email</h1>
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Use this link to verify your email:</p>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
<tbody>
<tr>
<td>
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Verify Email</span
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span
></a
>
</td>
</tr>
</tbody>
</table>
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody style="width: 100%">
<tr style="width: 100%">
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1 @@
Verify your email

View File

@@ -0,0 +1,52 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
<tbody>
<tr style="width: 100%">
<td>
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Reset Password</h1>
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Use this link to reset your password:</p>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
<tbody>
<tr>
<td>
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Reset Password</span
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span
></a
>
</td>
</tr>
</tbody>
</table>
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody style="width: 100%">
<tr style="width: 100%">
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1 @@
Reset your password

View File

@@ -0,0 +1,43 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
<tbody>
<tr style="width: 100%">
<td>
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">One-time Password</h1>
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">To sign in to ${redirectTo}, please, use the following one-time password:</p>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
<tbody>
<tr>
<td><p style="font-size: 24px; line-height: 32px; margin: 16px 0; color: #0052cd; font-weight: 600">${ticket}</p></td>
</tr>
</tbody>
</table>
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody style="width: 100%">
<tr style="width: 100%">
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1 @@
One-time password for ${redirectTo}

View File

@@ -0,0 +1 @@
Your code is ${code}.

View File

@@ -0,0 +1,52 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
<tbody>
<tr style="width: 100%">
<td>
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Magic Link</h1>
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Use this link to securely sign in:</p>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
<tbody>
<tr>
<td>
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Magic Link</span
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span
></a
>
</td>
</tr>
</tbody>
</table>
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody style="width: 100%">
<tr style="width: 100%">
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1 @@
Secure sign-in link

View File

@@ -0,0 +1,52 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
<tbody>
<tr style="width: 100%">
<td>
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Confirmar cambio de correo electrónico</h1>
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Utiliza el siguiente enlace para confirmar el cambio de correo:</p>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
<tbody>
<tr>
<td>
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Cambiar correo electrónico</span
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span
></a
>
</td>
</tr>
</tbody>
</table>
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody style="width: 100%">
<tr style="width: 100%">
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1 @@
Cambiar dirección de correo electrónico

View File

@@ -0,0 +1,52 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
<tbody>
<tr style="width: 100%">
<td>
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Verificar correo electrónico</h1>
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Utilza el siguiente enlace para verificar tu correo:</p>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
<tbody>
<tr>
<td>
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Verificar correo electrónico</span
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span
></a
>
</td>
</tr>
</tbody>
</table>
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody style="width: 100%">
<tr style="width: 100%">
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1 @@
Verifica tu correo electrónico

View File

@@ -0,0 +1,52 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
<tbody>
<tr style="width: 100%">
<td>
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Recuperar contraseña</h1>
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Utiliza el siguiente enlace para recuperar tu contraseña:</p>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
<tbody>
<tr>
<td>
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Recuperar contraseña</span
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span
></a
>
</td>
</tr>
</tbody>
</table>
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody style="width: 100%">
<tr style="width: 100%">
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1 @@
Recuperar contraseña

View File

@@ -0,0 +1,43 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
<tbody>
<tr style="width: 100%">
<td>
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">One-time Password</h1>
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Para iniciar sesión en ${redirectTo}, por favor, utilice la siguiente contraseña de un solo uso:</p>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
<tbody>
<tr>
<td><p style="font-size: 24px; line-height: 32px; margin: 16px 0; color: #0052cd; font-weight: 600">${ticket}</p></td>
</tr>
</tbody>
</table>
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody style="width: 100%">
<tr style="width: 100%">
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1 @@
Contraseña de un solo uso para ${redirectTo}

View File

@@ -0,0 +1 @@
Tu código es ${code}.

View File

@@ -0,0 +1,52 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
<tbody>
<tr style="width: 100%">
<td>
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Enlace mágico</h1>
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Utiliza este enlace para iniciar sesión de forma segura:</p>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
<tbody>
<tr>
<td>
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Iniciar sesión</span
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span
></a
>
</td>
</tr>
</tbody>
</table>
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody style="width: 100%">
<tr style="width: 100%">
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1 @@
Enlace de acceso seguro

View File

@@ -0,0 +1,52 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
<tbody>
<tr style="width: 100%">
<td>
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Confirmer changement de courriel</h1>
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Utilisez ce lien pour confirmer le changement de courriel:</p>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
<tbody>
<tr>
<td>
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Changer courriel</span
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span
></a
>
</td>
</tr>
</tbody>
</table>
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody style="width: 100%">
<tr style="width: 100%">
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1 @@
Changez votre adresse courriel

View File

@@ -0,0 +1,52 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
<tbody>
<tr style="width: 100%">
<td>
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Vérifiez votre courriel</h1>
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Utilisez ce lien pour vérifier votre courriel:</p>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
<tbody>
<tr>
<td>
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Vérifier courriel</span
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span
></a
>
</td>
</tr>
</tbody>
</table>
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody style="width: 100%">
<tr style="width: 100%">
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1 @@
Vérifier votre courriel

Some files were not shown because too many files have changed in this diff Show More