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> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3459/files#diff-5d346e2efc6817f6c3de4360c27031e4d5a4f37423aedb329f645aa9ac33d6e8">+594/-0</a> </td> </tr> <tr> <td><strong>upload.tsx</strong><dd><code>Add React Native file upload screen</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3459/files#diff-3763882523f551153b1520c05a953fdd2b3e7e0c37e2db53318e6a2f05d09b9f">+552/-0</a> </td> </tr> <tr> <td><strong>Upload.tsx</strong><dd><code>Add React SPA file upload page</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3459/files#diff-aadcba440d58667ce429ae0caad0695a7dddc8d91e1b8f0dc52ce6633b0eddc4">+444/-0</a> </td> </tr> <tr> <td><strong>SecurityKeys.tsx</strong><dd><code>Add React SPA WebAuthn security keys</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3459/files#diff-3f9cf11913bfd3220c4c220618b5575c6a3ed59ffa42d6d5be32c77edfc7d610">+404/-0</a> </td> </tr> <tr> <td><strong>client.tsx</strong><dd><code>Add Next.js SSR file upload client</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3459/files#diff-f6fa7dea8d8baf6d7fcefaf4517a492dbfca213afcf6b5d08d180a889947c0e5">+399/-0</a> </td> </tr> <tr> <td><strong>SecurityKeyClient.tsx</strong><dd><code>Add Next.js SSR WebAuthn key client</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3459/files#diff-c05c21b6e3c47e8d17ba69ebba93e3ccd4266e26fd4062e6de552ed98d46c603">+351/-0</a> </td> </tr> <tr> <td><strong>signup.tsx</strong><dd><code>Add React Native signup screen with tabs</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3459/files#diff-262b09b9dd7234cad96fe092d7131a23451c9e50b98c126c9e36599b3a127ac6">+387/-0</a> </td> </tr> <tr> <td><strong>signin.tsx</strong><dd><code>Add React Native signin screen with MFA</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3459/files#diff-3a4105c32f4aa4290e4fc0a12c2cc3121b4b0901c1429917087e26569c1c0a7a">+367/-0</a> </td> </tr> <tr> <td><strong>MFASettings.tsx</strong><dd><code>Add React SPA MFA settings component</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3459/files#diff-e43f9e6aec21b8620c86b9cdf9986e2e8dace52d87609b1c74cf6ab9a0639d21">+288/-0</a> </td> </tr> <tr> <td><strong>mfa-settings.tsx</strong><dd><code>Add Next.js SSR MFA settings client</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3459/files#diff-75767c82cc4c7eb779f3beb7ca5c5f9b2cc090347029ab44cc7f93b8b881c85b">+292/-0</a> </td> </tr> <tr> <td><strong>profile.tsx</strong><dd><code>Add React Native profile screen</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3459/files#diff-e996eb818728427e1c131ac6f3cba0bd506ad8f28486ee47c9ceb5b33dbc7869">+262/-0</a> </td> </tr> <tr> <td><strong>verify.tsx</strong><dd><code>Add React Native email verify screen</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3459/files#diff-93aeb3cfb31ac602e11e6031bb4860e81385c3c9693e5285c8f69988341cd045">+265/-0</a> </td> </tr> <tr> <td><strong>index.ts</strong><dd><code>Add Express server Nhost client example</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3459/files#diff-337bf1c26c434751c7c3e356598278e8e05482e41936a30c587cf68fd375092f">+126/-0</a> </td> </tr> <tr> <td><strong>email-confirm-change.tsx</strong><dd><code>Add email confirm-change template generator</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3459/files#diff-5ee9a92531c20cb74b9a49335eb0a65cdd73efa7d8c59020ea80163bba2cec72">+129/-0</a> </td> </tr> <tr> <td><strong>AppleSignIn.tsx</strong><dd><code>Add Expo Apple Sign In integration</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3459/files#diff-8fcf005753999f7770661afbc82063eb0637a2f959f03a77a94d4eef55b1d9cf">+112/-0</a> </td> </tr> <tr> <td><strong>render-emails.ts</strong><dd><code>Add email templates render script</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3459/files#diff-760314d4066ab3171a8dd9ec712e5e6b4e373f86531e66c502df06dab8f9dc7d">+82/-0</a> </td> </tr> <tr> <td><strong>auth.ts</strong><dd><code>Add Vue demo Nhost auth store</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3459/files#diff-db6008d5d08993870173045b4d2e978832e91943222dad7f6093537c48277e4f">+75/-0</a> </td> </tr> <tr> <td><strong>App.tsx</strong><dd><code>Set up React SPA routes and auth provider</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3459/files#diff-849f3aa52970f348de49a27094aac4e4b8cb8cf29580cada70d37f1a04249725">+77/-0</a> </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> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3459/files#diff-c7076012eb33d6f60049710638b5ad19c2f310b8c250c79f1905be7e0a30b00a">+12/-12</a> </td> </tr> <tr> <td><strong>useProjectLogs.test.ts</strong><dd><code>Update project logs tests to use CoreLogService</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3459/files#diff-13d900aa08d06962a09628136b893801ad62a96c3ff89d380c5c4b7ae92d891e">+9/-9</a> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </td> </tr> <tr> <td><strong>Makefile</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3459/files#diff-80fe306cf2e39e0852a6033f331195be7942ed7fb54b2cac6bd0139cc34d1bb6">+17/-0</a> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </td> </tr> <tr> <td><strong>Makefile</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3459/files#diff-5c1f8270d9eede9a24fa01068ede09fa13a8fca85eeda73328701b5781500ebe">+7/-0</a> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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:
94
.github/workflows/examples_demos_checks.yaml
vendored
Normal file
94
.github/workflows/examples_demos_checks.yaml
vendored
Normal 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')
|
||||
20
.github/workflows/wf_check.yaml
vendored
20
.github/workflows/wf_check.yaml
vendored
@@ -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
2
.gitignore
vendored
@@ -69,3 +69,5 @@ out/
|
||||
result
|
||||
|
||||
.vitest
|
||||
|
||||
.claude
|
||||
|
||||
3
.npmrc
3
.npmrc
@@ -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
43
biome.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
link-workspace-packages = false
|
||||
auto-install-peers = false
|
||||
resolution-mode=highest
|
||||
@@ -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
23599
dashboard/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
7461
docs/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
17
examples/demos/Makefile
Normal 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"
|
||||
39
examples/demos/ReactNativeDemo/.gitignore
vendored
Normal file
39
examples/demos/ReactNativeDemo/.gitignore
vendored
Normal 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
|
||||
95
examples/demos/ReactNativeDemo/APPLE_SIGN_IN_SETUP.md
Normal file
95
examples/demos/ReactNativeDemo/APPLE_SIGN_IN_SETUP.md
Normal 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/)
|
||||
169
examples/demos/ReactNativeDemo/README.md
Normal file
169
examples/demos/ReactNativeDemo/README.md
Normal 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/)
|
||||
410
examples/demos/ReactNativeDemo/README_MAGIC_LINKS.md
Normal file
410
examples/demos/ReactNativeDemo/README_MAGIC_LINKS.md
Normal 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)
|
||||
381
examples/demos/ReactNativeDemo/README_NATIVE_AUTHENTICATION.md
Normal file
381
examples/demos/ReactNativeDemo/README_NATIVE_AUTHENTICATION.md
Normal 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
|
||||
357
examples/demos/ReactNativeDemo/README_PROTECTED_ROUTES.md
Normal file
357
examples/demos/ReactNativeDemo/README_PROTECTED_ROUTES.md
Normal 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)
|
||||
530
examples/demos/ReactNativeDemo/README_SOCIAL_SIGNIN.md
Normal file
530
examples/demos/ReactNativeDemo/README_SOCIAL_SIGNIN.md
Normal 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)
|
||||
53
examples/demos/ReactNativeDemo/app.json
Normal file
53
examples/demos/ReactNativeDemo/app.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
54
examples/demos/ReactNativeDemo/app/_layout.tsx
Normal file
54
examples/demos/ReactNativeDemo/app/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
examples/demos/ReactNativeDemo/app/components/AppleSignIn.tsx
Normal file
112
examples/demos/ReactNativeDemo/app/components/AppleSignIn.tsx
Normal 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;
|
||||
594
examples/demos/ReactNativeDemo/app/components/MFASettings.tsx
Normal file
594
examples/demos/ReactNativeDemo/app/components/MFASettings.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
158
examples/demos/ReactNativeDemo/app/components/MagicLinkForm.tsx
Normal file
158
examples/demos/ReactNativeDemo/app/components/MagicLinkForm.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
120
examples/demos/ReactNativeDemo/app/index.tsx
Normal file
120
examples/demos/ReactNativeDemo/app/index.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
110
examples/demos/ReactNativeDemo/app/lib/nhost/AuthProvider.tsx
Normal file
110
examples/demos/ReactNativeDemo/app/lib/nhost/AuthProvider.tsx
Normal 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;
|
||||
44
examples/demos/ReactNativeDemo/app/lib/utils.ts
Normal file
44
examples/demos/ReactNativeDemo/app/lib/utils.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
262
examples/demos/ReactNativeDemo/app/profile.tsx
Normal file
262
examples/demos/ReactNativeDemo/app/profile.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
367
examples/demos/ReactNativeDemo/app/signin.tsx
Normal file
367
examples/demos/ReactNativeDemo/app/signin.tsx
Normal 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'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",
|
||||
},
|
||||
});
|
||||
233
examples/demos/ReactNativeDemo/app/signin/mfa.tsx
Normal file
233
examples/demos/ReactNativeDemo/app/signin/mfa.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
387
examples/demos/ReactNativeDemo/app/signup.tsx
Normal file
387
examples/demos/ReactNativeDemo/app/signup.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
552
examples/demos/ReactNativeDemo/app/upload.tsx
Normal file
552
examples/demos/ReactNativeDemo/app/upload.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
265
examples/demos/ReactNativeDemo/app/verify.tsx
Normal file
265
examples/demos/ReactNativeDemo/app/verify.tsx
Normal 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'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",
|
||||
},
|
||||
});
|
||||
BIN
examples/demos/ReactNativeDemo/assets/images/adaptive-icon.png
Normal file
BIN
examples/demos/ReactNativeDemo/assets/images/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
examples/demos/ReactNativeDemo/assets/images/splash-icon.png
Normal file
BIN
examples/demos/ReactNativeDemo/assets/images/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
58
examples/demos/ReactNativeDemo/package.json
Normal file
58
examples/demos/ReactNativeDemo/package.json
Normal 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
|
||||
}
|
||||
6278
examples/demos/ReactNativeDemo/pnpm-lock.yaml
generated
Normal file
6278
examples/demos/ReactNativeDemo/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
examples/demos/ReactNativeDemo/tsconfig.json
Normal file
15
examples/demos/ReactNativeDemo/tsconfig.json
Normal 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
2
examples/demos/backend/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.nhost
|
||||
.secrets
|
||||
16
examples/demos/backend/.secrets.example
Normal file
16
examples/demos/backend/.secrets.example
Normal 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-----'''
|
||||
7
examples/demos/backend/Makefile
Normal file
7
examples/demos/backend/Makefile
Normal 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
|
||||
29
examples/demos/backend/README.md
Normal file
29
examples/demos/backend/README.md
Normal 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
|
||||
8
examples/demos/backend/env-up.sh
Executable file
8
examples/demos/backend/env-up.sh
Executable 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
|
||||
14
examples/demos/backend/functions/package-lock.json
generated
Normal file
14
examples/demos/backend/functions/package-lock.json
generated
Normal 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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
13
examples/demos/backend/functions/package.json
Normal file
13
examples/demos/backend/functions/package.json
Normal 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"
|
||||
}
|
||||
11
examples/demos/backend/functions/tsconfig.json
Normal file
11
examples/demos/backend/functions/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": false
|
||||
}
|
||||
}
|
||||
1
examples/demos/backend/nhost/config.yaml
Normal file
1
examples/demos/backend/nhost/config.yaml
Normal file
@@ -0,0 +1 @@
|
||||
version: 3
|
||||
@@ -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>   </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>   ​</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>
|
||||
@@ -0,0 +1 @@
|
||||
Потвърждение за смяна на имейл
|
||||
@@ -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>   </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>   ​</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>
|
||||
@@ -0,0 +1 @@
|
||||
Потвърждаване на имейл
|
||||
@@ -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>   </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>   ​</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>
|
||||
@@ -0,0 +1 @@
|
||||
Смяна на парола
|
||||
43
examples/demos/backend/nhost/emails/bg/signin-otp/body.html
Normal file
43
examples/demos/backend/nhost/emails/bg/signin-otp/body.html
Normal 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>
|
||||
@@ -0,0 +1 @@
|
||||
Еднократна парола за ${redirectTo}
|
||||
@@ -0,0 +1 @@
|
||||
Вашият код е ${code}.
|
||||
@@ -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>   </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>   ​</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>
|
||||
@@ -0,0 +1 @@
|
||||
Магически линк за вход
|
||||
@@ -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>   </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>   ​</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>
|
||||
@@ -0,0 +1 @@
|
||||
Změna vaší emailové adresy
|
||||
@@ -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>   </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>   ​</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>
|
||||
@@ -0,0 +1 @@
|
||||
Ověření vaší emailové adresy
|
||||
@@ -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>   </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>   ​</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>
|
||||
@@ -0,0 +1 @@
|
||||
Obnova hesla
|
||||
43
examples/demos/backend/nhost/emails/cs/signin-otp/body.html
Normal file
43
examples/demos/backend/nhost/emails/cs/signin-otp/body.html
Normal 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>
|
||||
@@ -0,0 +1 @@
|
||||
Jednorázové heslo pro ${redirectTo}
|
||||
@@ -0,0 +1 @@
|
||||
Váš kód je ${code}.
|
||||
@@ -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>   </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>   ​</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>
|
||||
@@ -0,0 +1 @@
|
||||
Bezpečný odkaz k přihlášení
|
||||
@@ -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>   </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>   ​</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>
|
||||
@@ -0,0 +1 @@
|
||||
Change your email address
|
||||
@@ -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>   </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>   ​</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>
|
||||
@@ -0,0 +1 @@
|
||||
Verify your email
|
||||
@@ -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>   </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>   ​</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>
|
||||
@@ -0,0 +1 @@
|
||||
Reset your password
|
||||
43
examples/demos/backend/nhost/emails/en/signin-otp/body.html
Normal file
43
examples/demos/backend/nhost/emails/en/signin-otp/body.html
Normal 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>
|
||||
@@ -0,0 +1 @@
|
||||
One-time password for ${redirectTo}
|
||||
@@ -0,0 +1 @@
|
||||
Your code is ${code}.
|
||||
@@ -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>   </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>   ​</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>
|
||||
@@ -0,0 +1 @@
|
||||
Secure sign-in link
|
||||
@@ -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>   </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>   ​</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>
|
||||
@@ -0,0 +1 @@
|
||||
Cambiar dirección de correo electrónico
|
||||
@@ -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>   </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>   ​</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>
|
||||
@@ -0,0 +1 @@
|
||||
Verifica tu correo electrónico
|
||||
@@ -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>   </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>   ​</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>
|
||||
@@ -0,0 +1 @@
|
||||
Recuperar contraseña
|
||||
43
examples/demos/backend/nhost/emails/es/signin-otp/body.html
Normal file
43
examples/demos/backend/nhost/emails/es/signin-otp/body.html
Normal 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>
|
||||
@@ -0,0 +1 @@
|
||||
Contraseña de un solo uso para ${redirectTo}
|
||||
@@ -0,0 +1 @@
|
||||
Tu código es ${code}.
|
||||
@@ -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>   </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>   ​</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>
|
||||
@@ -0,0 +1 @@
|
||||
Enlace de acceso seguro
|
||||
@@ -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>   </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>   ​</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>
|
||||
@@ -0,0 +1 @@
|
||||
Changez votre adresse courriel
|
||||
@@ -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>   </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>   ​</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>
|
||||
@@ -0,0 +1 @@
|
||||
Vérifier votre courriel
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user