feat (docs): add tutorials for supported frameworks (#3469)
### **PR Type** Enhancement ___ ### **Description** - Add cross-framework Todos CRUD examples - Introduce file upload/download tutorials - Provide unified AuthProvider context implementations - Include email templates and backend actions ___ <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>19 files</summary><table> <tr> <td><strong>Todos.tsx</strong><dd><code>Add web demo Todos CRUD page</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-53f3b3d582fef21d5ec90cb590b73afcf09407071dba60883ed1ed7360955fc5">+648/-0</a> </td> </tr> <tr> <td><strong>todos.tsx</strong><dd><code>Add React Native tutorial Todos</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-3f6be5ef4f443091687addd404fe71f219498b9db7dea992d18d78b4f1b6ffa3">+561/-0</a> </td> </tr> <tr> <td><strong>commonStyles.ts</strong><dd><code>Add common React Native tutorial styles</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-bf0de16179ecc80a8e88e223c890dc2c73c30b4a9b7cadd62e910ca015ce342b">+667/-0</a> </td> </tr> <tr> <td><strong>Todos.tsx</strong><dd><code>Add React tutorial Todos page</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-be700e4847b0a745821f156c381e583097f2083123065a45a20611c2ba1876a7">+504/-0</a> </td> </tr> <tr> <td><strong>files.tsx</strong><dd><code>Add React Native tutorial file upload</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-d116805053a943f271a3297d06d14dba39c0f5775080e67e1e9e2778c176e9da">+454/-0</a> </td> </tr> <tr> <td><strong>Files.tsx</strong><dd><code>Add React tutorial Files page</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-cdbca4ed68690df84463df7765dff52c85a60502f175c19519c8b42474e9282c">+404/-0</a> </td> </tr> <tr> <td><strong>signup.tsx</strong><dd><code>Enhance signup flow with email verification</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-262b09b9dd7234cad96fe092d7131a23451c9e50b98c126c9e36599b3a127ac6">+185/-141</a></td> </tr> <tr> <td><strong>FilesClient.tsx</strong><dd><code>Add Next.js Files client component</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-ea74386f232b9ae7e7957ab4eb1f0d1d6076b338173e8b1e917369fb7f1b39bb">+359/-0</a> </td> </tr> <tr> <td><strong>TodosClient.tsx</strong><dd><code>Add Next.js Todos client component</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-96efb4db6bd61a7faee3c383e77b092eccee1a1876770c36691e7356268cfad4">+368/-0</a> </td> </tr> <tr> <td><strong>AuthProvider.tsx</strong><dd><code>Implement React AuthProvider context</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-4cc7e41420d71d448eeec4f77043e0c5bff2c606986439454dade5ffcd433e33">+174/-0</a> </td> </tr> <tr> <td><strong>AuthProvider.tsx</strong><dd><code>Implement RN AuthProvider context</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-93f9f27a35d0039a64cd6889a296d04d37542aa5a777925e61e8e60ee5a6d744">+148/-0</a> </td> </tr> <tr> <td><strong>actions.ts</strong><dd><code>Add Next.js server actions for Todos</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-76d57097940d3043c8e0ab29761767861b78fe86ab8a90a2d8209f1818131d31">+223/-0</a> </td> </tr> <tr> <td><strong>signup.tsx</strong><dd><code>Add RN signup tutorial screen</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-153c2f0bf6b3744bb84b95e356dd78c8771206a1b22218bc4c6f90641e4143ad">+183/-0</a> </td> </tr> <tr> <td><strong>explore.tsx</strong><dd><code>Add RN tutorial Explore screen</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-980313b6c7f75f2ecd45fc476895bea122364d000f5988e21564bf5db73d7f57">+125/-0</a> </td> </tr> <tr> <td><strong>route.ts</strong><dd><code>Add Next.js file download route</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-5c39cd02d478ad625a7cdb6df3f7b6d20a76f40488636fdd87282c66174b2bd8">+57/-0</a> </td> </tr> <tr> <td><strong>SignUp.tsx</strong><dd><code>Enhance demo signup with verification state</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-75657efa0e1c29f59692ced3cd90e9c734836977900dc64015dd5d217bb263da">+38/-3</a> </td> </tr> <tr> <td><strong>SignUpForm.tsx</strong><dd><code>Add Next.js signup client form</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-9034cd412c47033a01e604a8250984aa1d1ecefc9884b79ebd7f7f3af17e3167">+89/-0</a> </td> </tr> <tr> <td><strong>SignInForm.tsx</strong><dd><code>Add Next.js signin client form</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-c276aadee4507d515832909164d447c3c1a4870277d0adbd1f7e836f7c66259e">+75/-0</a> </td> </tr> <tr> <td><strong>server.tsx</strong><dd><code>Add server Nhost client for Next.js</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-9d650defc223584e4aa06ddbf1d9a97c47a5a7ec4c9589a72ac7ea5369853400">+89/-0</a> </td> </tr> </table></details></td></tr><tr><td><strong>Configuration changes</strong></td><td><details><summary>1 files</summary><table> <tr> <td><strong>_layout.tsx</strong><dd><code>Configure RN tutorial tab navigation</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-c3b0bac088aef9f1a2d5cd2f4b51dd19fb301034668a062860a1e6a3512c15c9">+39/-0</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_tutorials_checks.yaml</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-745712dc409289d30fe4caa74de567df9cc9fac97750ae5566f93a51e48bf539">+94/-0</a> </td> </tr> <tr> <td><strong>docs.json</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-873ce17c654718debe2fe308a2f2279bde8663686423c51f97fab2dd0722b8d9">+52/-7</a> </td> </tr> <tr> <td><strong>overview.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-8c9b35da559a5de5fe14ee078573e8d487453e26ed760c03ffd7f0ad476ca24d">+5/-5</a> </td> </tr> <tr> <td><strong>nextjs.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-a5210d45e7d33a57d43078dbe2a2ccbf0667b157291fd92c3986092d7d33ab9c">+0/-508</a> </td> </tr> <tr> <td><strong>1-introduction.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-35413705a524a3eb2c7d096daef02d660b479fbc288674aa260e3f159988652b">+116/-0</a> </td> </tr> <tr> <td><strong>2-protected-routes.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-d901b3a6d8a96e3070f27afb934c34365aa79aeee1505238de2cb77a9ffd8546">+1364/-0</a></td> </tr> <tr> <td><strong>3-user-authentication.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-7b96576ad1b1b6b8e50dd3889e24df391662df1b7b51a111f0239f655135939f">+804/-0</a> </td> </tr> <tr> <td><strong>4-graphql-operations.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-16906637a292d22c018167d45f2da67a17fe73155ca161de91c3b329fee9d399">+958/-0</a> </td> </tr> <tr> <td><strong>5-file-uploads.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-d6153781dbf251499f2ffeeb707102349776d788859c2da0f63c8b0a8e35c821">+822/-0</a> </td> </tr> <tr> <td><strong>react.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-6f5adda9f7b29d98c68cab6ec754c0bac501666a49dd635ee830789e2c812b68">+0/-497</a> </td> </tr> <tr> <td><strong>1-introduction.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-4dac916b2b27af668674965d9680f4ba8d2a417f7b155d4b052b1043fc71beb6">+116/-0</a> </td> </tr> <tr> <td><strong>2-protected-routes.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-8ab0206ae4a4c73e540cf549fdbdd9de4ed7c8d59cc88ea26a4a7ddc0a9a4460">+1435/-0</a></td> </tr> <tr> <td><strong>3-user-authentication.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-e02466ba1abd5d457724c7508da083a94b16d700cab36384567592c7d772cbf6">+647/-0</a> </td> </tr> <tr> <td><strong>4-graphql-operations.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-ae759670f7bf513c66bc08952556514e7193f23eb7cc536aebf38ad79e12c02f">+856/-0</a> </td> </tr> <tr> <td><strong>5-file-uploads.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-02dc5fa1da67b0478bb797ed99c89685be3ec4509023441fb29b730ba74fff4e">+715/-0</a> </td> </tr> <tr> <td><strong>reactnative.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-a85b3aa1caaeaa0a2cb6219ca531f89aaf2b23b41cd424c189b3cb948af1fd57">+0/-443</a> </td> </tr> <tr> <td><strong>1-introduction.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-573f696fb008f0e047e97209a1595b1f5c69dd5a1fe4907b65d6883273c53096">+121/-0</a> </td> </tr> <tr> <td><strong>2-protected-routes.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-b4ab0ba037b62e9eb5ee10cf5f56fd1c148f1e053f95291e42113d9f924ae711">+1499/-0</a></td> </tr> <tr> <td><strong>3-user-authentication.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-dda89bc27be58e7fa58aef9aeb937dce455f69d9566e734a10ef4e0df993ab42">+805/-0</a> </td> </tr> <tr> <td><strong>4-graphql-operations.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-59a46fa74310dc797cdfd6e1a34d09fd8002e9accdade9d865b898419addc2bc">+881/-0</a> </td> </tr> <tr> <td><strong>5-file-uploads.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-cb48bca7fdd422250b2482167b9a136d76ab60d2d5efccb94fb1a022b814d449">+776/-0</a> </td> </tr> <tr> <td><strong>6-sign-in-with-apple.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-a4da2a574e88122505df17fcf1f9705841a8240b33f60b6fcde86e068b8b6cfe">+684/-0</a> </td> </tr> <tr> <td><strong>1-introduction.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-1eb8289a9ffc0484d2f8f21ede882c9e892d169f36b69f2ed9af940e2d1b8faa">+116/-0</a> </td> </tr> <tr> <td><strong>2-protected-routes.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-bff6efc64eeda0dae0d2a133ab6c9be9dfe725ee24b364b89060ec93344ad436">+1308/-0</a></td> </tr> <tr> <td><strong>3-user-authentication.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-f6bac1bb9476f921272f61911c703529663fb6ff37217c38d1702a1c8b70171a">+578/-0</a> </td> </tr> <tr> <td><strong>4-graphql-operations.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-3a43d1ebc0b4fd7f74699c4f33ea110bba5ffa2695e6228dccafbfa38798c633">+763/-0</a> </td> </tr> <tr> <td><strong>5-file-uploads.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-2244e838acdbe909772f82f66cc63800aa9dbf5fae292a40d2d4f663d6618dd4">+642/-0</a> </td> </tr> <tr> <td><strong>sveltekit.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-9ed4ad735d1142a65a2da2dbbd8d46c508b7aef3d032cdb102d0f329602c4ce1">+0/-497</a> </td> </tr> <tr> <td><strong>vue.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-f6c4215fa6909fd3accebe0691a7364d17befb8ef90da5a4aeaee83d598c0540">+0/-504</a> </td> </tr> <tr> <td><strong>1-introduction.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-9485f8a6ad6398b19aa7a3739af3eef5158a7d285dc5a55e8bc6c1d58970fb84">+116/-0</a> </td> </tr> <tr> <td><strong>2-protected-routes.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-5ba36ef4388ad18b7efc6d405e2b66a69b063d483d4521d6acb5b5da65aee97a">+1368/-0</a></td> </tr> <tr> <td><strong>3-user-authentication.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-63f56a07c48f78462b47f74420e58cb8411d46aef880cfdf24b11ddea4bb382e">+637/-0</a> </td> </tr> <tr> <td><strong>4-graphql-operations.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-ad7a84d0c924b946d992f5277cde596fb9d42eff021b888b5a8046822afabca8">+828/-0</a> </td> </tr> <tr> <td><strong>5-file-uploads.mdx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-00c26651dea4650fd77222a24b31387569067783a87d9c6715935e2bf133428e">+720/-0</a> </td> </tr> <tr> <td><strong>auth_user_security_keys.yaml</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-385036f3c18b6efd97138842e95f992a0569f612f627e71091c10952d8d31609">+2/-11</a> </td> </tr> <tr> <td><strong>auth_users.yaml</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-aba47a0c058f0a0a8122dee4d7b4394c4a8997bac9d4ccea04c41f0d00819050">+0/-24</a> </td> </tr> <tr> <td><strong>storage_files.yaml</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-ea0619f75a1f7dd13ca80d81cc2e28529594a73fff0356dc0b1d49ee0d1cc9ad">+17/-36</a> </td> </tr> <tr> <td><strong>tables.yaml</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-a57c35142b6c4029fad7d5c8407305a61d18078cd0e264d41286625cf5cc4d30">+1/-5</a> </td> </tr> <tr> <td><strong>down.sql</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-d7be59389936d96f1c3b10f2147b177d488906d2155139536fae635201859c3e">+1/-0</a> </td> </tr> <tr> <td><strong>up.sql</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-69c6c175ba9819f7b41d6a27e78fd0390e222665c9cc95fbb7fde702f14a1f4c">+26/-0</a> </td> </tr> <tr> <td><strong>nhost.toml</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-45587ca86b6441ceacd1318e72a60ee0c30c70c00edb0a06cf061e404998f3bc">+4/-4</a> </td> </tr> <tr> <td><strong>actions.ts</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-a0279ec5849eb0c93ec7c2f444fbecd2b681f47d954a84da5221f72d9805c2ca">+12/-0</a> </td> </tr> <tr> <td><strong>page.tsx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-8fffbc65277c93c49eea289ae1174cdba0632e5a295b74668437e9b1b0669097">+61/-30</a> </td> </tr> <tr> <td><strong>middleware.ts</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-817666e0f29fa78d2d7f77ed93cff4efbc8d85a996ab58d0dd13ad61e47c9125">+5/-3</a> </td> </tr> <tr> <td><strong>App.tsx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-849f3aa52970f348de49a27094aac4e4b8cb8cf29580cada70d37f1a04249725">+2/-0</a> </td> </tr> <tr> <td><strong>Navigation.tsx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-9845c755953914963f404a14104d93cd326f78e48614ece8c4d9df3ab1600ffd">+3/-0</a> </td> </tr> <tr> <td><strong>index.css</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-8e415aa656412ff29b45f56035e84691f3abcc924bfb5b4e3cd0c9ba5237543b">+200/-0</a> </td> </tr> <tr> <td><strong>+page.svelte</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-00da2a0f738ffa5c66a07437c7e85cacb7780551bad7e81899d905f065bf0892">+29/-8</a> </td> </tr> <tr> <td><strong>main.css</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-1d2ba653024753e96f2db949f818dccba45498456146171c4a2f883b58177d2f">+373/-0</a> </td> </tr> <tr> <td><strong>Navigation.vue</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-27e2f662a54c47023a1342005bcf92602a846ad7a59e9d7d528aa8fbf40a0250">+7/-0</a> </td> </tr> <tr> <td><strong>index.ts</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-3bf427bc13a501168d321cf4bd428d09d8c3a042ef3be955c7a2e99bbf5f39dd">+7/-0</a> </td> </tr> <tr> <td><strong>SignUp.vue</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-1838012876d16508d53d0345615b91c7c6fbccca3c9333d2311ac5fcd7b4c210">+28/-3</a> </td> </tr> <tr> <td><strong>Todos.vue</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-3334950776983589e7061d69be930b71663329a6ba56726bcbd3f1c2352dab47">+466/-0</a> </td> </tr> <tr> <td><strong>Makefile</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-85a3083c78e211e9eb36d741342bcbc85a1a0c375060f45c5426b560196de27f">+2/-2</a> </td> </tr> <tr> <td><strong>.secrets.example</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-be7a988f18be877344f8584befc094ec2e66fe7784a2007300053707ad8ec7f1">+16/-0</a> </td> </tr> <tr> <td><strong>Makefile</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-36623bab6fe16382fd3e61b06b9586f2b14bea7c1b492e50db14ea98935016a4">+7/-0</a> </td> </tr> <tr> <td><strong>README.md</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-ed02e93d01774c3796335c83badc90cbc8a2035040965031946b69c8d91db3dd">+29/-0</a> </td> </tr> <tr> <td><strong>env-up.sh</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-b6846a8b7ff5eea35ba5ff2aac8773f526c118ef59712a750f69578f25afda54">+8/-0</a> </td> </tr> <tr> <td><strong>package.json</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-f0d63d57cf77fdb7f2526d10c2d32d51ed2b6d43e3495d3f8879499e0c04f04b">+13/-0</a> </td> </tr> <tr> <td><strong>tsconfig.json</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-cad0e83f030efa9fcad494c7797838c5099909f2c2ae0de0210812b966ffa6db">+11/-0</a> </td> </tr> <tr> <td><strong>config.yaml</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-997042f08b088725275a28a7e2d275b86e2e74f3c972800e8116785d5d0fea59">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-3c921023f82fc74f5b657cb92bc8906ea19d7eb8f746713e4fae2a5a894a8a59">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-5f67fb54ebc23bfccd1e23fbb7dc955bad8ad9011fae19369c87bf3f4d3e58fa">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-3c21997bc2db49d5714afc007f214316d17eb69d8ea70dd1cfe558bdb47df4bf">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-e3ab8737d4a9ea68a115f28ce578b7502107f68212d92f035bb86178ccdfb6fe">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-ed34cbd1c3e612ac3124ccce382b84ea7cda7eb22051b07102b68f5c28c58e97">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-b8cead9967aaddc7c52be376fcd54b27c6ec223445e0063729c7255ab6c4f5fc">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-295cddc1774338f5b6ba396ef5ca9a0832b9b2346703ec22abf3b3874f903cc2">+43/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-de3e6c264684c0dddb2f6e70dcb236fe8cb1a27789c01a11b2a707c110ce7fc2">+1/-0</a> </td> </tr> <tr> <td><strong>body.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-ae93f5f97d52d1d42be91b50e2a44522aef884065559f993f781f1ebeaa89b66">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-13511e1d49a2796e2ee0bb2082f02ed59c49bef7f7533735e60ac62ad16cc2c6">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-293b460447946e2de5e4ba74cf64895a4f6fff7d879bb2e555c46ad7edd857a8">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-843300abe10319ac2b6ccffdd8daf3445b8eaa839486acb95566d398cad4e053">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-8ff22c43814b945c0a22ea9089755a144e54039bdd12a2f06a128b4b155b3e38">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-f9e07ca4b5b9ae54e1668c3dfb8ac32612df366eb7d44819163f17c792507295">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-4f829f73c5bce9bcbb67ed1357a111cdf9974c36d2db22493067d201b35f4cdf">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-64f6273ffcec137bbad9a104cd46e6a8776915a804bb3f89c24701071cbb2c50">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-e19bb81615465996e46da50eacac7fddf71dd8bb18200c410b1837575bd76f95">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-a202edc97b202ec41b83decb7b358a6beb89f3edef46194b576ecaada51f073d">+43/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-c2338d16f093d53b8a86b9a859c7bef0d9ef2c83a762d46cd49ab9e2f1f2a773">+1/-0</a> </td> </tr> <tr> <td><strong>body.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-7fff5a89330dd91d3c9781e37d8f1e1f48bfc58c686b463b7fb1f43a2d01813d">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-c9031a1f7ac5ad3d0c3c94cff2f563560c1e3c05d1acc00353e603f3dcac439e">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-847d33f3faf4a7f928445eb389ba8aa24901b9025d4bae0c38becd5e9365a881">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-3f5c827ff0c5a3270dc4c7737b0afd09327800104cb379ed2a556029eb58aef3">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-e499fcf53b46507a64be23ae309eba07e2c3ee289be4de316bd8ee83c16ea42e">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-5a6337adab234155f13e6b7d63eac5c654d5c75f4ea207c9db16b67685ce3e43">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-b8f9c01ea13429ca9990a95e81ac6bedb273ec41198f34903584e9a68c22c6ef">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-5bfe4c29aee11546871f9127b5c5a954f313c64d2344cd4e6cd402fe22cf0ded">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-262bf98a3f4801aff364e19a4c00bf37197764454b274faf1dbd947506b843a7">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-8a4ad52e5c3cd679ede51f7d411ca5aaf89f2f519f49c540e09408bf13823323">+43/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-46f8a60e052ff7b9b3d8583f83f36b826aea9c7a24af9e9998cc0b699900b3b1">+1/-0</a> </td> </tr> <tr> <td><strong>body.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-03da104febe035cf096fccbce0dbbf32197d289de814810792a319154241200c">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-d7fff652e5e21d234b1ab733de23d179d5901ff82165ffbd9495b3a1ab1c7611">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-a84317c8ffe3b56140c923228666555d0e9e93c34eb5a8376b08ab97e6e4ae96">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-5025ef2a43d19c4f668177eea4e8002664a22522e11fef04aabbcdb2fbff3178">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-e941c399a4d0b15de5bf2b20e722c2e6ffc990ecdef15efc2367ae302a52ff63">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-126da45f26e2d9256a28fadd38f363277926de563e6b303027cbea24779c729c">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-4686c2e7f183e071c8ccd456189d9bd056ead4874078dba16b1a2b1e53c7e9e4">+1/-0</a> </td> </tr> <tr> <td><strong>body.html</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-331385785301bc1e55cfb6a3c707e3704b32d82a38f7ccd9aa4aa834a8569df4">+52/-0</a> </td> </tr> <tr> <td><strong>subject.txt</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-57d1b1020e0d2e327acca4154b0e4776f3a7a5db1d6c99edae3ee45561780c6a">+1/-0</a> </td> </tr> <tr> <td><strong>Additional files not shown</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-2f328e4cd8dbe3ad193e49d92bcf045f47a6b72b1e9487d366f6b8288589b4ca"></a></td> </tr> </table></details></td></tr></tr></tbody></table> </details> ___
94
.github/workflows/examples_tutorials_checks.yaml
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: "examples/tutorials: check and build"
|
||||
on:
|
||||
# pull_request_target:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/wf_check.yaml'
|
||||
- '.github/workflows/examples_tutorials_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/**'
|
||||
|
||||
# tutorials
|
||||
- 'examples/tutorials/**'
|
||||
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: tutorials
|
||||
PATH: examples/tutorials
|
||||
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: tutorials
|
||||
PATH: examples/tutorials
|
||||
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')
|
||||
@@ -41,14 +41,59 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Tutorials",
|
||||
"icon": "book",
|
||||
"group": "Tutorial: ToDo App (React)",
|
||||
"icon": "react",
|
||||
"pages": [
|
||||
"/getting-started/tutorials/react",
|
||||
"/getting-started/tutorials/nextjs",
|
||||
"/getting-started/tutorials/vue",
|
||||
"/getting-started/tutorials/sveltekit",
|
||||
"/getting-started/tutorials/reactnative"
|
||||
"/getting-started/tutorials/react/1-introduction",
|
||||
"/getting-started/tutorials/react/2-protected-routes",
|
||||
"/getting-started/tutorials/react/3-user-authentication",
|
||||
"/getting-started/tutorials/react/4-graphql-operations",
|
||||
"/getting-started/tutorials/react/5-file-uploads"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Tutorial: ToDo App (Next.js)",
|
||||
"icon": "triangle",
|
||||
"pages": [
|
||||
"/getting-started/tutorials/nextjs/1-introduction",
|
||||
"/getting-started/tutorials/nextjs/2-protected-routes",
|
||||
"/getting-started/tutorials/nextjs/3-user-authentication",
|
||||
"/getting-started/tutorials/nextjs/4-graphql-operations",
|
||||
"/getting-started/tutorials/nextjs/5-file-uploads"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Tutorial: ToDo App (Vue)",
|
||||
"icon": "vuejs",
|
||||
"pages": [
|
||||
"/getting-started/tutorials/vue/1-introduction",
|
||||
"/getting-started/tutorials/vue/2-protected-routes",
|
||||
"/getting-started/tutorials/vue/3-user-authentication",
|
||||
"/getting-started/tutorials/vue/4-graphql-operations",
|
||||
"/getting-started/tutorials/vue/5-file-uploads"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Tutorial: ToDo App (Svelte)",
|
||||
"icon": "s",
|
||||
"pages": [
|
||||
"/getting-started/tutorials/svelte/1-introduction",
|
||||
"/getting-started/tutorials/svelte/2-protected-routes",
|
||||
"/getting-started/tutorials/svelte/3-user-authentication",
|
||||
"/getting-started/tutorials/svelte/4-graphql-operations",
|
||||
"/getting-started/tutorials/svelte/5-file-uploads"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Tutorial: ToDo App (React Native)",
|
||||
"icon": "mobile-notch",
|
||||
"pages": [
|
||||
"/getting-started/tutorials/reactnative/1-introduction",
|
||||
"/getting-started/tutorials/reactnative/2-protected-routes",
|
||||
"/getting-started/tutorials/reactnative/3-user-authentication",
|
||||
"/getting-started/tutorials/reactnative/4-graphql-operations",
|
||||
"/getting-started/tutorials/reactnative/5-file-uploads",
|
||||
"/getting-started/tutorials/reactnative/6-sign-in-with-apple"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -65,35 +65,35 @@ Follow one of your tutorials where we walk you through building a Todo Manager a
|
||||
<Card
|
||||
title="Next.js"
|
||||
icon="react"
|
||||
href="/getting-started/tutorials/nextjs"
|
||||
href="/getting-started/tutorials/nextjs/1-introduction"
|
||||
>
|
||||
Todo Manager with Nhost and NextJS
|
||||
</Card>
|
||||
<Card
|
||||
title="React"
|
||||
icon="react"
|
||||
href="/getting-started/tutorials/react"
|
||||
href="/getting-started/tutorials/react/1-introduction"
|
||||
>
|
||||
Todo Manager with Nhost and React
|
||||
</Card>
|
||||
<Card
|
||||
title="Vue"
|
||||
icon="vuejs"
|
||||
href="/getting-started/tutorials/vue"
|
||||
href="/getting-started/tutorials/vue/1-introduction"
|
||||
>
|
||||
Todo Manager with Nhost and Vue
|
||||
</Card>
|
||||
<Card
|
||||
title="Svelte"
|
||||
icon="S"
|
||||
href="/getting-started/tutorials/sveltekit"
|
||||
href="/getting-started/tutorials/svelte/1-introduction"
|
||||
>
|
||||
Todo Manager with Nhost and SvelteKit
|
||||
</Card>
|
||||
<Card
|
||||
title="React Native"
|
||||
icon="mobile-notch"
|
||||
href="/getting-started/tutorials/reactnative"
|
||||
href="/getting-started/tutorials/reactnative/1-introduction"
|
||||
>
|
||||
Todo Manager with Nhost and React Native
|
||||
</Card>
|
||||
|
||||
@@ -1,508 +0,0 @@
|
||||
---
|
||||
title: Build a Todo Manager with Next.js
|
||||
description: Learn how to use Nhost with Next.js
|
||||
sidebarTitle: Next.js
|
||||
icon: react
|
||||
---
|
||||
|
||||
In this tutorial, you will build a simple **Todo Manager** application with Nhost and Next.js. Along the way you will interact with the Database, Authentication, and Storage services.
|
||||
|
||||
The Todo Manager will allow users to see public `todos` and sign in using a Magic Link to manage their own `todos` with attachments.
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="Database">
|
||||
To store todos
|
||||
</Card>
|
||||
|
||||
<Card title="Auth">
|
||||
To sign in users
|
||||
</Card>
|
||||
|
||||
<Card title="Storage">
|
||||
To store attachments
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
|
||||
## Setup Nhost Backend
|
||||
|
||||
In this section, you will create and setup your first Nhost project.
|
||||
|
||||
### Create project
|
||||
|
||||
Create a new project in the [Nhost Dashboard](https://app.nhost.io).
|
||||
|
||||
Enter the details for your project and wait a couple of minutes while Nhost provisions your backend infrastructure:
|
||||
|
||||
- Dedicated PostgreSQL
|
||||
- Realtime APIs over your data
|
||||
- Authentication for managing your users
|
||||
- Storage for handling files
|
||||
|
||||
### Create table `todos`
|
||||
|
||||
On the project's dashboard, navigate to **Database** and create a new table called `todos`.
|
||||
|
||||

|
||||
|
||||
You can either copy and paste the following SQL into the SQL Editor, **Database -> SQL Editor**, or manually create the table by clicking on **New Table**.
|
||||
|
||||
|
||||
<Tabs>
|
||||
<Tab title="SQL Editor">
|
||||
Copy and paste the following SQL into the SQL Editor and press **Run**.
|
||||
|
||||
<Note>Please make sure to enable **Track this** so that the new table `todos` is available through the auto-generated APIs</Note>
|
||||
|
||||
```sql SQL
|
||||
CREATE TABLE public.todos (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
created_at timestamptz DEFAULT now() NOT NULL,
|
||||
updated_at timestamptz DEFAULT now() NOT NULL,
|
||||
title text NOT NULL,
|
||||
completed bool DEFAULT 'false' NOT NULL,
|
||||
file_id uuid,
|
||||
user_id uuid NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (file_id) REFERENCES storage.files (id) ON UPDATE SET NULL ON DELETE SET NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES auth.users (id) ON UPDATE SET NULL ON DELETE SET NULL
|
||||
);
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="UI">
|
||||
Click on **New Table** and fill in the details for the `todos` table as shown.
|
||||
|
||||

|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
You should now see a new table called `todos` on the left panel, below **New Table**.
|
||||
|
||||
### Set permissions for todos
|
||||
|
||||
It's now time to set permission rules for the table you just created. With the table `todos` selected, click on **...**, followed by **Edit Permissions**.
|
||||
|
||||
You will set permissions for the `user` role and actions `insert`, `select`, `update`, and `delete`.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="insert">
|
||||
Click on the right cell for the `user` role and action `insert` and set permissions as follows:
|
||||

|
||||
</Tab>
|
||||
<Tab title="select">
|
||||
Click on the right cell for the `user` role and action `select` and set permissions as follows:
|
||||

|
||||
</Tab>
|
||||
<Tab title="update">
|
||||
Click on the right cell for the `user` role and action `update` and set permissions as follows:
|
||||

|
||||
</Tab>
|
||||
<Tab title="delete">
|
||||
Click on the right cell for the `user` role and action `delete` and set permissions as follows:
|
||||

|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Set permissions for files
|
||||
|
||||
The `files` table is managed by Nhost and is defined on the `storage` schema. Click on the dropdown right next to `schema.public` and choose `schema.storage`.
|
||||
|
||||
With the `files` table selected, click on **...**, followed by **Edit Permissions**.
|
||||
|
||||
As before, we want to set permissions for the `user` role and actions `insert`, `select`, `delete`.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="insert">
|
||||
Click on the right cell for the `user` role and action `insert` and set permissions as follows:
|
||||

|
||||
</Tab>
|
||||
<Tab title="select">
|
||||
Click on the right cell for the `user` role and action `select` and set permissions as follows:
|
||||

|
||||
</Tab>
|
||||
<Tab title="delete">
|
||||
Click on the right cell for the `user` role and action `delete` and set permissions as follows:
|
||||

|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Enable Sign In with Magic Link
|
||||
|
||||
To enable Magic Links, navigate to your project's **Settings -> Sign-In Methods**, toggle Magic Link, and save.
|
||||
|
||||
### Recap
|
||||
|
||||
<Steps>
|
||||
<Step title="Nhost project created">
|
||||
</Step>
|
||||
|
||||
<Step title="Database todos created">
|
||||
</Step>
|
||||
|
||||
<Step title="Permissions set for todos and files">
|
||||
</Step>
|
||||
|
||||
<Step title="Magic Link enabled">
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Setup Next.js Application
|
||||
|
||||
Now that we have Nhost configured, let's move on to setup the React application and the Nhost client.
|
||||
|
||||
### Create React Application
|
||||
|
||||
Run the following command in your terminal to create a React application using Vite.
|
||||
|
||||
```bash Terminal
|
||||
npx create-next-app@next-14 --no-eslint \
|
||||
--src-dir \
|
||||
--no-tailwind \
|
||||
--import-alias "@/*" \
|
||||
--js \
|
||||
--app \
|
||||
nhost-nextjs
|
||||
```
|
||||
|
||||
### Install Nhost React package
|
||||
|
||||
To install Nhost's React package, run the following command.
|
||||
|
||||
```bash Terminal
|
||||
cd nhost-nextjs && npm install @nhost/nextjs
|
||||
```
|
||||
|
||||
#### Configure the Nhost Client
|
||||
|
||||
Create a new file with the following code to create a Nhost client. Replace `<SUBDOMAIN>` and `<REGION>` with the values from the project created earlier.
|
||||
|
||||
```ts ./src/lib/nhost.ts
|
||||
import { NhostClient } from "@nhost/nextjs";
|
||||
|
||||
export const nhost = new NhostClient({
|
||||
subdomain: "<SUBDOMAIN>",
|
||||
region: "<REGION>"
|
||||
});
|
||||
```
|
||||
|
||||
<Info>The project's `subdomain` and `region` can be found in the Nhost Dashboard under **Project Info**</Info>
|
||||
|
||||
### Setup Sign In Component
|
||||
|
||||
It is time to setup a new React component to handle the login functionality. Users will be able to sign in using a Magic Link.
|
||||
|
||||
Create a new file with the following content:
|
||||
|
||||
```js ./src/app/signin.js
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useSignInEmailPasswordless } from '@nhost/nextjs'
|
||||
|
||||
export default function SignIn() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [email, setEmail] = useState('')
|
||||
|
||||
const { signInEmailPasswordless, error } = useSignInEmailPasswordless()
|
||||
|
||||
const handleSignIn = async (event) => {
|
||||
event.preventDefault()
|
||||
|
||||
setLoading(true)
|
||||
const { error } = await signInEmailPasswordless(email)
|
||||
|
||||
if (error) {
|
||||
console.error({ error })
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
alert('Magic Link Sent!')
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Todo Manager</h1>
|
||||
<p>powered by Nhost and React</p>
|
||||
<form onSubmit={handleSignIn}>
|
||||
<div>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Your email"
|
||||
value={email}
|
||||
required={true}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button disabled={loading}>
|
||||
{loading ? <span>Loading</span> : <span>Send me a Magic Link!</span>}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p>{error.message}</p>}
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Setup `Todos` Component
|
||||
|
||||
Now that users can sign in, let's move on and create the authenticated page that lists a user's todos and has a form for managing todos with attachments.
|
||||
|
||||
```js ./src/app/todos.jsx
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNhostClient, useFileUpload } from '@nhost/nextjs'
|
||||
|
||||
const deleteTodo = `
|
||||
mutation($id: uuid!) {
|
||||
delete_todos_by_pk(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
const createTodo = `
|
||||
mutation($title: String!, $file_id: uuid) {
|
||||
insert_todos_one(object: {title: $title, file_id: $file_id}) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
const getTodos = `
|
||||
query {
|
||||
todos {
|
||||
id
|
||||
title
|
||||
file_id
|
||||
completed
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export default function Todos() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [todos, setTodos] = useState([])
|
||||
|
||||
const [todoTitle, setTodoTitle] = useState('')
|
||||
const [todoAttachment, setTodoAttachment] = useState(null)
|
||||
const [fetchAll, setFetchAll] = useState(false)
|
||||
|
||||
const nhostClient = useNhostClient()
|
||||
const { upload } = useFileUpload()
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchTodos() {
|
||||
setLoading(true)
|
||||
const { data, error } = await nhostClient.graphql.request(getTodos)
|
||||
|
||||
if (error) {
|
||||
console.error({ error })
|
||||
return
|
||||
}
|
||||
|
||||
setTodos(data.todos)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
fetchTodos()
|
||||
|
||||
return () => {
|
||||
setFetchAll(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fetchAll])
|
||||
|
||||
const handleCreateTodo = async (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
let todo = { title: todoTitle }
|
||||
if (todoAttachment) {
|
||||
const { id, error } = await upload({
|
||||
file: todoAttachment,
|
||||
name: todoAttachment.name
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error({ error })
|
||||
return
|
||||
}
|
||||
|
||||
todo.file_id = id
|
||||
}
|
||||
|
||||
const { error } = await nhostClient.graphql.request(createTodo, todo)
|
||||
|
||||
if (error) {
|
||||
console.error({ error })
|
||||
}
|
||||
|
||||
setTodoTitle('')
|
||||
setTodoAttachment(null)
|
||||
setFetchAll(true)
|
||||
}
|
||||
|
||||
const handleDeleteTodo = async (id) => {
|
||||
if (!window.confirm('Are you sure you want to delete this TODO?')) {
|
||||
return
|
||||
}
|
||||
|
||||
const todo = todos.find((todo) => todo.id === id)
|
||||
if (todo.file_id) {
|
||||
await nhostClient.storage.delete({ fileId: todo.file_id })
|
||||
}
|
||||
|
||||
const { error } = await nhostClient.graphql.request(deleteTodo, { id })
|
||||
if (error) {
|
||||
console.error({ error })
|
||||
}
|
||||
|
||||
setFetchAll(true)
|
||||
}
|
||||
|
||||
const completeTodo = async (id) => {
|
||||
const { error } = await nhostClient.graphql.request(
|
||||
`
|
||||
mutation($id: uuid!) {
|
||||
update_todos_by_pk(pk_columns: {id: $id}, _set: {completed: true}) {
|
||||
completed
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ id }
|
||||
)
|
||||
|
||||
if (error) {
|
||||
console.error({ error })
|
||||
}
|
||||
|
||||
setFetchAll(true)
|
||||
}
|
||||
|
||||
const openAttachment = async (todo) => {
|
||||
const { presignedUrl, error } = await nhostClient.storage.getPresignedUrl({
|
||||
fileId: todo.file_id
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error({ error })
|
||||
return
|
||||
}
|
||||
|
||||
window.open(presignedUrl.url, '_blank')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="container">
|
||||
<div className="form-section">
|
||||
<h2>Add a new TODO</h2>
|
||||
<form onSubmit={handleCreateTodo}>
|
||||
<div className="input-group">
|
||||
<label htmlFor="title">Title</label>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
value={todoTitle}
|
||||
onChange={(e) => setTodoTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label htmlFor="file">File (optional)</label>
|
||||
<input id="file" type="file" onChange={(e) => setTodoAttachment(e.target.files[0])} />
|
||||
</div>
|
||||
<div className="submit-group">
|
||||
<button type="submit" disabled={!todoTitle}>
|
||||
Add Todo
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="todos-section">
|
||||
{(!loading &&
|
||||
todos.map((todo) => (
|
||||
<div className="todo-item" key={todo.id ?? 0}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={todo.completed}
|
||||
disabled={todo.completed}
|
||||
id={`todo-${todo.id}`}
|
||||
onChange={() => completeTodo(todo.id)}
|
||||
/>
|
||||
{todo.file_id && (
|
||||
<span>
|
||||
<a onClick={() => openAttachment(todo)}> Open Attachment</a>
|
||||
</span>
|
||||
)}
|
||||
<label htmlFor={`todo-${todo.id}`} className="todo-title">
|
||||
{todo.completed && <s>{todo.title}</s>}
|
||||
{!todo.completed && todo.title}
|
||||
</label>
|
||||
<button type="button" onClick={() => handleDeleteTodo(todo.id)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
))) || (
|
||||
<div className="todo-item">
|
||||
<label className="todo-title">Loading...</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sign-out-section">
|
||||
<button type="button" onClick={() => nhostClient.auth.signOut()}>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
With both `SignIn` and `Todos` in place, update `./src/App.jsx` to use the new components:
|
||||
|
||||
```js ./src/app/App.js
|
||||
"use client";
|
||||
|
||||
import './App.css'
|
||||
import { NhostProvider } from '@nhost/nextjs'
|
||||
import { nhost } from '../lib/nhost.js'
|
||||
import SignIn from './signin'
|
||||
import Todos from './todos'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
function App() {
|
||||
const [session, setSession] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
setSession(nhost.auth.getSession())
|
||||
|
||||
nhost.auth.onAuthStateChanged((_, session) => {
|
||||
setSession(session)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<NhostProvider nhost={nhost}>
|
||||
{session ? <Todos session={session} /> : <SignIn />}
|
||||
</NhostProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
```
|
||||
|
||||
|
||||
## The End
|
||||
|
||||
Run the Todo Manager with:
|
||||
|
||||
```bash Terminal
|
||||
npm run dev -- --port 3000
|
||||
```
|
||||
|
||||
Open your browser on [localhost:3000](localhost:3000) to see your new application in action.
|
||||
116
docs/getting-started/tutorials/nextjs/1-introduction.mdx
Normal file
@@ -0,0 +1,116 @@
|
||||
---
|
||||
title: Create Your Nhost Project
|
||||
description: Learn how to create and set up a new Nhost project to get started building your Next.js application
|
||||
sidebarTitle: Create Project
|
||||
icon: plus
|
||||
---
|
||||
|
||||
Welcome to the **Full-Stack Next.js Development with Nhost** series! In this comprehensive tutorial series, you'll build a complete React application with Nhost that demonstrates authentication, database operations, and file management.
|
||||
|
||||
## About This Tutorial Series
|
||||
|
||||
This tutorial series is divided into **5 parts**, each focusing on a specific aspect of building modern web applications with Nhost and Next.js. By the end of the series, you'll have built a fully functional application featuring:
|
||||
|
||||
- **User Authentication** - Complete sign up, sign in, and email verification flow
|
||||
- **Todo Management** - Users can create, update, delete, and mark todos as complete
|
||||
- **File Uploads** - Users can upload and manage files with proper permissions
|
||||
- **Protected Routes** - Secure areas that only authenticated users can access
|
||||
|
||||
<Info>
|
||||
This is **Part 1** in the Full-Stack Next.js Development with Nhost series. This tutorial sets up the foundation by creating your Nhost project and understanding the series structure.
|
||||
</Info>
|
||||
|
||||
## Full-Stack Next.js Development with Nhost
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/nextjs/1-introduction">
|
||||
**Current** - Set up your Nhost project
|
||||
</Card>
|
||||
|
||||
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/nextjs/2-protected-routes">
|
||||
Route protection basics
|
||||
</Card>
|
||||
|
||||
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/nextjs/3-user-authentication">
|
||||
Complete auth flow
|
||||
</Card>
|
||||
|
||||
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/nextjs/4-graphql-operations">
|
||||
CRUD operations with GraphQL
|
||||
</Card>
|
||||
|
||||
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/nextjs/5-file-uploads">
|
||||
File upload and management
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## What You'll Learn
|
||||
|
||||
Throughout this series, you'll master:
|
||||
|
||||
- Setting up and configuring Nhost projects
|
||||
- Implementing secure authentication flows
|
||||
- Building protected routes with Next.js Router
|
||||
- Performing GraphQL queries and mutations
|
||||
- Managing file uploads and storage
|
||||
- Configuring database permissions and security
|
||||
- Building responsive Next.js interfaces
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 20+ installed on your machine
|
||||
- Basic knowledge of Next.js and JavaScript
|
||||
- Understanding of modern web development concepts
|
||||
|
||||
Creating an Nhost project is the first step to building your application with Nhost. Let's get started by setting up your backend infrastructure.
|
||||
|
||||
## Step-by-Step Guide
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
|
||||
### Sign Up or Log in
|
||||
|
||||
If you don't have an Nhost account, sign up at [Nhost](https://app.nhost.io/). If you already have an account, log in.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Create a New Project
|
||||
|
||||
Click on the "Create Project" button on your dashboard or follow the onboarding prompts if you're a new user.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Take note of your project subdomain and region
|
||||
|
||||
Take note of your project subdomain and region. You will need this information to connect your application to the Nhost backend in upcoming tutorials.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
## What's Next?
|
||||
|
||||
With your Nhost project created, you now have access to:
|
||||
|
||||
- [**PostgreSQL Database**](/products/database/overview) - For storing your application data
|
||||
- [**Authentication Service**](/products/auth/overview) - For managing users and sessions
|
||||
- [**GraphQL API**](/products/graphql/overview) - For querying and mutating data
|
||||
- [**File Storage**](/products/storage/overview) - For uploading and managing files
|
||||
- [**Functions**](/products/functions/overview) - For running serverless functions
|
||||
|
||||
In the [next tutorial](/getting-started/tutorials/nextjs/2-protected-routes), you'll start building your Next.js application and learn how to protect routes based on user authentication status.
|
||||
|
||||
<Tip>
|
||||
Keep your project subdomain and region handy - you'll need them throughout the series to connect your Next.js application to the Nhost backend.
|
||||
</Tip>
|
||||
1364
docs/getting-started/tutorials/nextjs/2-protected-routes.mdx
Normal file
804
docs/getting-started/tutorials/nextjs/3-user-authentication.mdx
Normal file
@@ -0,0 +1,804 @@
|
||||
---
|
||||
title: User Authentication in Next.js
|
||||
description: Learn how to implement user authentication in a Next.js application using Nhost
|
||||
sidebarTitle: "User Authentication"
|
||||
icon: user
|
||||
---
|
||||
|
||||
This tutorial part builds upon the [Protected Routes part](/getting-started/tutorials/nextjs/2-protected-routes) by adding complete email/password authentication with email verification functionality. You'll implement sign up, sign in, email verification, and sign out features to create a full authentication flow.
|
||||
|
||||
<Info>
|
||||
This is **Part 3** in the Full-Stack Next.js Development with Nhost series. This part creates a production-ready authentication system with secure email verification and proper error handling using Next.js App Router.
|
||||
</Info>
|
||||
|
||||
## Full-Stack Next.js Development with Nhost
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/nextjs/1-introduction">
|
||||
Set up your Nhost project
|
||||
</Card>
|
||||
|
||||
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/nextjs/2-protected-routes">
|
||||
Route protection basics
|
||||
</Card>
|
||||
|
||||
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/nextjs/3-user-authentication">
|
||||
**Current** - Complete auth flow
|
||||
</Card>
|
||||
|
||||
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/nextjs/4-graphql-operations">
|
||||
CRUD operations with GraphQL
|
||||
</Card>
|
||||
|
||||
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/nextjs/5-file-uploads">
|
||||
File upload and management
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Complete the [Protected Routes part](/getting-started/tutorials/nextjs/2-protected-routes) first
|
||||
- The project from the previous part set up and running
|
||||
|
||||
## Step-by-Step Guide
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
|
||||
### Create the Sign In Flow
|
||||
|
||||
In this step, we'll create a complete sign-in flow using Next.js App Router patterns. We'll build three key files: a server component for the main page that handles URL parameters, a client component for the interactive form, and server actions for secure authentication processing.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Page Component">
|
||||
|
||||
The main sign-in page is a **server component** that handles URL parameters (like error messages) and renders the sign-in form. This component runs on the server and can access search parameters directly.
|
||||
|
||||
```tsx src/app/signin/page.tsx
|
||||
import Link from "next/link";
|
||||
import SignInForm from "./SignInForm";
|
||||
|
||||
export default async function SignIn({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ error?: string }>;
|
||||
}) {
|
||||
// Extract error from URL parameters
|
||||
const params = await searchParams;
|
||||
const error = params?.error;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Sign In</h1>
|
||||
<SignInForm initialError={error} />
|
||||
|
||||
<div className="auth-links">
|
||||
<p>
|
||||
Don't have an account? <Link href="/signup">Sign Up</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="Form Component">
|
||||
|
||||
The sign-in form is a **client component** that handles user interactions, loading states, and form submissions. It communicates with server actions and provides real-time feedback to users.
|
||||
|
||||
```tsx src/app/signin/SignInForm.tsx
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useId, useState } from "react";
|
||||
import { signIn } from "./actions";
|
||||
|
||||
interface SignInFormProps {
|
||||
initialError?: string;
|
||||
}
|
||||
|
||||
export default function SignInForm({ initialError }: SignInFormProps) {
|
||||
const [error, setError] = useState<string | undefined>(initialError);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const emailId = useId();
|
||||
const passwordId = useId();
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
setIsLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
try {
|
||||
const result = await signIn(formData);
|
||||
|
||||
if (result.redirect) {
|
||||
router.push(result.redirect);
|
||||
} else if (result.error) {
|
||||
setError(result.error);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "An error occurred during sign in",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form action={handleSubmit} className="auth-form">
|
||||
<div className="auth-form-field">
|
||||
<label htmlFor={emailId}>Email</label>
|
||||
<input
|
||||
id={emailId}
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
className="auth-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="auth-form-field">
|
||||
<label htmlFor={passwordId}>Password</label>
|
||||
<input
|
||||
id={passwordId}
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
className="auth-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="auth-error">{error}</div>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="auth-button secondary"
|
||||
>
|
||||
{isLoading ? "Signing In..." : "Sign In"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="Server Actions">
|
||||
|
||||
Server actions handle the authentication logic securely on the server side. They validate form data, communicate with Nhost, and return appropriate responses for success or error states.
|
||||
|
||||
```tsx src/app/signin/actions.ts
|
||||
"use server";
|
||||
|
||||
import type { ErrorResponse } from "@nhost/nhost-js/auth";
|
||||
import type { FetchError } from "@nhost/nhost-js/fetch";
|
||||
import { createNhostClient } from "../../lib/nhost/server";
|
||||
|
||||
export async function signIn(formData: FormData) {
|
||||
const email = formData.get("email") as string;
|
||||
const password = formData.get("password") as string;
|
||||
|
||||
if (!email || !password) {
|
||||
return {
|
||||
error: "Email and password are required",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const nhost = await createNhostClient();
|
||||
|
||||
const response = await nhost.auth.signInEmailPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (response.body?.session) {
|
||||
return { redirect: "/profile" };
|
||||
} else {
|
||||
return {
|
||||
error: "Failed to sign in. Please check your credentials.",
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err as FetchError<ErrorResponse>;
|
||||
return {
|
||||
error: `An error occurred during sign in: ${error.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Create the Sign Up Flow
|
||||
|
||||
In this step, we'll build the user registration system with email verification support. The sign-up flow includes handling both the registration form and the email verification success state, all using Next.js server and client components.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Page Component">
|
||||
|
||||
The sign-up page is a **server component** that manages different states: showing the registration form or displaying the email verification success message. It handles URL parameters to determine which state to render.
|
||||
|
||||
```tsx src/app/signup/page.tsx
|
||||
import Link from "next/link";
|
||||
import SignUpForm from "./SignUpForm";
|
||||
|
||||
export default async function SignUp({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{
|
||||
error?: string;
|
||||
verify?: string;
|
||||
email?: string;
|
||||
}>;
|
||||
}) {
|
||||
// Extract parameters from URL
|
||||
const params = await searchParams;
|
||||
const error = params?.error;
|
||||
const verificationSent = params?.verify === "success";
|
||||
const email = params?.email;
|
||||
|
||||
if (verificationSent) {
|
||||
return (
|
||||
<div>
|
||||
<h1>Check Your Email</h1>
|
||||
<div className="success-message">
|
||||
<p>
|
||||
We've sent a verification link to <strong>{email}</strong>
|
||||
</p>
|
||||
<p>
|
||||
Please check your email and click the verification link to activate
|
||||
your account.
|
||||
</p>
|
||||
</div>
|
||||
<p>
|
||||
<Link href="/signin">Back to Sign In</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Sign Up</h1>
|
||||
<SignUpForm initialError={error} />
|
||||
|
||||
<div className="auth-links">
|
||||
<p>
|
||||
Already have an account? <Link href="/signin">Sign In</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="Form Component">
|
||||
|
||||
The registration form is a **client component** that collects user information (display name, email, password) and handles form validation, loading states, and error feedback during the sign-up process.
|
||||
|
||||
```tsx src/app/signup/SignUpForm.tsx
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useId, useState } from "react";
|
||||
import { signUp } from "./actions";
|
||||
|
||||
interface SignUpFormProps {
|
||||
initialError?: string;
|
||||
}
|
||||
|
||||
export default function SignUpForm({ initialError }: SignUpFormProps) {
|
||||
const [error, setError] = useState<string | undefined>(initialError);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const displayNameId = useId();
|
||||
const emailId = useId();
|
||||
const passwordId = useId();
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
setIsLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
try {
|
||||
const result = await signUp(formData);
|
||||
|
||||
if (result.redirect) {
|
||||
router.push(result.redirect);
|
||||
} else if (result.error) {
|
||||
setError(result.error);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "An error occurred during sign up",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form action={handleSubmit} className="auth-form">
|
||||
<div className="auth-form-field">
|
||||
<label htmlFor={displayNameId}>Display Name</label>
|
||||
<input
|
||||
id={displayNameId}
|
||||
name="displayName"
|
||||
type="text"
|
||||
required
|
||||
className="auth-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="auth-form-field">
|
||||
<label htmlFor={emailId}>Email</label>
|
||||
<input
|
||||
id={emailId}
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
className="auth-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="auth-form-field">
|
||||
<label htmlFor={passwordId}>Password</label>
|
||||
<input
|
||||
id={passwordId}
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
className="auth-input"
|
||||
/>
|
||||
<small className="help-text">Minimum 8 characters</small>
|
||||
</div>
|
||||
|
||||
{error && <div className="auth-error">{error}</div>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="auth-button primary"
|
||||
>
|
||||
{isLoading ? "Creating Account..." : "Sign Up"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="Server Actions">
|
||||
|
||||
Server actions handle user registration with Nhost, including setting up email verification. They process form data, create user accounts, and coordinate the email verification flow by setting the appropriate redirect URLs.
|
||||
|
||||
```tsx src/app/signup/actions.ts
|
||||
"use server";
|
||||
|
||||
import type { ErrorResponse } from "@nhost/nhost-js/auth";
|
||||
import type { FetchError } from "@nhost/nhost-js/fetch";
|
||||
import { createNhostClient } from "../../lib/nhost/server";
|
||||
|
||||
export async function signUp(formData: FormData) {
|
||||
const email = formData.get("email") as string;
|
||||
const password = formData.get("password") as string;
|
||||
const displayName = formData.get("displayName") as string;
|
||||
|
||||
if (!email || !password || !displayName) {
|
||||
return {
|
||||
error: "All fields are required",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const nhost = await createNhostClient();
|
||||
|
||||
const response = await nhost.auth.signUpEmailPassword({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
displayName,
|
||||
// Set the redirect URL for email verification
|
||||
redirectTo: `${process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"}/verify`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.body?.session) {
|
||||
// Successfully signed up and automatically signed in
|
||||
return { redirect: "/profile" };
|
||||
} else {
|
||||
// Verification email sent
|
||||
return {
|
||||
redirect: `/signup?verify=success&email=${encodeURIComponent(email)}`,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err as FetchError<ErrorResponse>;
|
||||
return {
|
||||
error: `An error occurred during sign up: ${error.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Create the Email Verification System
|
||||
|
||||
In this step, we'll implement email verification using Next.js Route Handlers. When users click the verification link in their email, it will process the token server-side and either redirect them to their profile or show an error page with debugging information.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Route Handler">
|
||||
|
||||
The verification Route Handler is a **server-side API endpoint** that processes email verification tokens. It validates the token, handles edge cases (like already signed-in users), and redirects appropriately based on the verification result.
|
||||
|
||||
```tsx src/app/verify/route.ts
|
||||
import type { ErrorResponse } from "@nhost/nhost-js/auth";
|
||||
import type { FetchError } from "@nhost/nhost-js/fetch";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { createNhostClient } from "../../lib/nhost/server";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const refreshToken = request.nextUrl.searchParams.get("refreshToken");
|
||||
|
||||
if (!refreshToken) {
|
||||
// Collect all query parameters for debugging
|
||||
const params = new URLSearchParams(request.nextUrl.searchParams);
|
||||
params.set("message", "No refresh token provided");
|
||||
|
||||
return NextResponse.redirect(
|
||||
new URL(`/verify/error?${params.toString()}`, request.url),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const nhost = await createNhostClient();
|
||||
|
||||
if (nhost.getUserSession()) {
|
||||
// Collect all query parameters
|
||||
const params = new URLSearchParams(request.nextUrl.searchParams);
|
||||
params.set("message", "Already signed in");
|
||||
|
||||
return NextResponse.redirect(
|
||||
new URL(`/verify/error?${params.toString()}`, request.url),
|
||||
);
|
||||
}
|
||||
|
||||
// Process the verification token
|
||||
await nhost.auth.refreshToken({ refreshToken });
|
||||
|
||||
// Redirect to profile on successful verification
|
||||
return NextResponse.redirect(new URL("/profile", request.url));
|
||||
} catch (err) {
|
||||
const error = err as FetchError<ErrorResponse>;
|
||||
const errorMessage = `Failed to verify token: ${error.message}`;
|
||||
|
||||
// Collect all query parameters
|
||||
const params = new URLSearchParams(request.nextUrl.searchParams);
|
||||
params.set("message", errorMessage);
|
||||
|
||||
return NextResponse.redirect(
|
||||
new URL(`/verify/error?${params.toString()}`, request.url),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="Error Page">
|
||||
|
||||
The verification error page is a **server component** that displays helpful error messages and debugging information when email verification fails. It shows the specific error message and any URL parameters that might help diagnose the issue.
|
||||
|
||||
```tsx src/app/verify/error/page.tsx
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function VerifyError({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const message = params?.message || "Unknown verification error";
|
||||
|
||||
// Filter out the message to show other URL parameters
|
||||
const urlParams = Object.entries(params).filter(([key]) => key !== "message");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Email Verification</h1>
|
||||
|
||||
<div className="page-center">
|
||||
<p className="verification-status error">Verification failed</p>
|
||||
<p className="margin-bottom">{message}</p>
|
||||
|
||||
{urlParams.length > 0 && (
|
||||
<div className="debug-panel">
|
||||
<p className="debug-title">URL Parameters:</p>
|
||||
{urlParams.map(([key, value]) => (
|
||||
<div key={key} className="debug-item">
|
||||
<span className="debug-key">{key}:</span>{" "}
|
||||
<span className="debug-value">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link href="/signin" className="auth-button secondary">
|
||||
Back to Sign In
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
<Warning>
|
||||
**Important Configuration Required:** Before testing email verification, you must configure your Nhost project's authentication settings:
|
||||
|
||||
1. Go to your Nhost project dashboard
|
||||
2. Navigate to **Settings → Authentication**
|
||||
3. Add your local development URL (e.g., `http://localhost:3000`) to the **Allowed Redirect URLs** field
|
||||
4. Ensure your production domain is also added when deploying
|
||||
|
||||
Without this configuration, you'll receive a `redirectTo not allowed` error when users attempt to sign up or verify their email addresses.
|
||||
</Warning>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Create the Sign Out System
|
||||
|
||||
In this step, we'll implement user sign-out functionality using Next.js patterns. We'll create a client component for the sign-out button and a server action to handle the actual sign-out process securely on the server side.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Sign Out Button">
|
||||
|
||||
The sign-out button is a **client component** that provides an interactive button for users to sign out. It handles the user interaction and calls the server action, then manages navigation and component refresh after sign-out.
|
||||
|
||||
```tsx src/components/SignOutButton.tsx
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signOut } from "../lib/nhost/actions";
|
||||
|
||||
export default function SignOutButton() {
|
||||
const router = useRouter();
|
||||
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
await signOut();
|
||||
router.push("/");
|
||||
router.refresh(); // Refresh to update server components
|
||||
} catch (err) {
|
||||
console.error("Error signing out:", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSignOut}
|
||||
className="nav-link nav-button"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="Server Action">
|
||||
|
||||
The sign-out server action handles the authentication logic securely on the server side. It retrieves the current session, calls Nhost's sign-out method with the refresh token, and redirects the user to the home page after successful sign-out.
|
||||
|
||||
```tsx src/lib/nhost/actions.ts
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { createNhostClient } from "./server";
|
||||
|
||||
export async function signOut() {
|
||||
try {
|
||||
const nhost = await createNhostClient();
|
||||
const session = nhost.getUserSession();
|
||||
|
||||
if (session) {
|
||||
await nhost.auth.signOut({
|
||||
refreshToken: session.refreshToken,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error signing out:", err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
redirect("/");
|
||||
}
|
||||
```
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Update Navigation Component
|
||||
|
||||
In this step, we'll update the server-side navigation component that shows different links based on the user's authentication state. The navigation will display "Sign In" and "Sign Up" links for unauthenticated users, and "Profile" and "Sign Out" for authenticated users.
|
||||
|
||||
```tsx src/components/Navigation.tsx lines highlight={3,26,30-35}
|
||||
import Link from "next/link";
|
||||
import { createNhostClient } from "../lib/nhost/server";
|
||||
import SignOutButton from "./SignOutButton";
|
||||
|
||||
export default async function Navigation() {
|
||||
const nhost = await createNhostClient();
|
||||
const session = nhost.getUserSession();
|
||||
|
||||
return (
|
||||
<nav className="navigation">
|
||||
<div className="nav-container">
|
||||
<Link href="/" className="nav-logo">
|
||||
Nhost Next.js Demo
|
||||
</Link>
|
||||
|
||||
<div className="nav-links">
|
||||
<Link href="/" className="nav-link">
|
||||
Home
|
||||
</Link>
|
||||
|
||||
{session ? (
|
||||
<>
|
||||
<Link href="/profile" className="nav-link">
|
||||
Profile
|
||||
</Link>
|
||||
<SignOutButton />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/signin" className="nav-link">
|
||||
Sign In
|
||||
</Link>
|
||||
<Link href="/signup" className="nav-link">
|
||||
Sign Up
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Update Public Routes in Middleware
|
||||
|
||||
In this step, we'll configure the middleware to allow access to authentication-related routes without requiring authentication. This ensures that users can access sign-in, sign-up, and email verification pages even when not logged in.
|
||||
|
||||
```tsx src/middleware.ts lines highlight={6}
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { handleNhostMiddleware } from "./lib/nhost/server";
|
||||
|
||||
// Define public routes that don't require authentication
|
||||
const publicRoutes = ["/", "/signin", "/signup", "/verify", "/verify/error"];
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
// Create a response that we'll modify as needed
|
||||
const response = NextResponse.next();
|
||||
|
||||
// Get the current path
|
||||
const path = request.nextUrl.pathname;
|
||||
|
||||
// Check if this is a public route or a public asset
|
||||
const isPublicRoute = publicRoutes.some(
|
||||
(route) => path === route || path.startsWith(`${route}/`),
|
||||
);
|
||||
|
||||
// Handle Nhost authentication and token refresh
|
||||
// Always call this to ensure session is up-to-date
|
||||
// even for public routes, so that session changes are detected
|
||||
const session = await handleNhostMiddleware(request, response);
|
||||
|
||||
// If it's a public route, allow access without checking auth
|
||||
if (isPublicRoute) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// If no session and not a public route, redirect to signin
|
||||
if (!session) {
|
||||
const homeUrl = new URL("/", request.url);
|
||||
return NextResponse.redirect(homeUrl);
|
||||
}
|
||||
|
||||
// Session exists, allow access to protected route
|
||||
return response;
|
||||
}
|
||||
|
||||
// Define which routes this middleware should run on
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except:
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
* - public files (public directory)
|
||||
*/
|
||||
"/((?!_next/static|_next/image|favicon.ico|public).*)",
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Run and Test the Application
|
||||
|
||||
Start your Next.js development server and test the complete authentication flow to ensure everything works properly.
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Things to try out:
|
||||
|
||||
1. **Email Verification Flow**: Try signing up with a new email address. Check your email for the verification link and click it. The verification route handler will process the token and redirect you to your profile.
|
||||
2. **Sign In/Out Flow**: Try signing out and then signing back in with the same credentials using the server actions.
|
||||
3. **Server-Side Navigation**: Notice how navigation links change based on authentication state - the navigation component is rendered server-side and shows different content based on the session.
|
||||
4. **Route Protection**: Try accessing protected routes while logged out to see the middleware-based protection in action.
|
||||
5. **Cross-Tab Consistency**: Open multiple tabs and test signing out from one tab. Unlike client-side React apps, you'll need to refresh or navigate to see changes in other tabs due to server-side rendering.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Key Features Demonstrated
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Server Components & Actions" icon="server">
|
||||
Full authentication flow using Next.js App Router with server components and server actions for secure, server-side processing.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Route Handlers" icon="route">
|
||||
Custom `/verify` Route Handler that securely processes email verification tokens server-side with proper error handling.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Client/Server Separation" icon="arrows-split-up-and-left">
|
||||
Clear separation between server components for rendering and client components for interactivity, following Next.js best practices.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Error Handling" icon="triangle-exclamation">
|
||||
Comprehensive error handling with URL-based error states and dedicated error pages for different failure scenarios.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Session Management" icon="clock">
|
||||
Server-side session handling with sign out functionality using server actions and proper state management.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
958
docs/getting-started/tutorials/nextjs/4-graphql-operations.mdx
Normal file
@@ -0,0 +1,958 @@
|
||||
---
|
||||
title: GraphQL Operations in Next.js
|
||||
description: Learn how to perform GraphQL operations and manage database permissions while building a complete todos application with Nhost and Next.js
|
||||
sidebarTitle: "GraphQL Operations"
|
||||
icon: code
|
||||
---
|
||||
|
||||
This part builds upon the previous parts by demonstrating how to perform GraphQL operations with proper database permissions using Next.js App Router patterns. You'll learn how to design database tables, configure user permissions, and implement complete CRUD operations through GraphQL queries and mutations using server components, client components, and server actions.
|
||||
|
||||
<Info>
|
||||
This is **Part 4** in the Full-Stack Next.js Development with Nhost series. This part focuses on GraphQL operations, database management, and permission-based data access control using Next.js App Router with server/client component separation.
|
||||
</Info>
|
||||
|
||||
## Full-Stack Next.js Development with Nhost
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/nextjs/1-introduction">
|
||||
Set up your Nhost project
|
||||
</Card>
|
||||
|
||||
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/nextjs/2-protected-routes">
|
||||
Route protection basics
|
||||
</Card>
|
||||
|
||||
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/nextjs/3-user-authentication">
|
||||
Complete auth flow
|
||||
</Card>
|
||||
|
||||
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/nextjs/4-graphql-operations">
|
||||
**Current** - CRUD operations with GraphQL
|
||||
</Card>
|
||||
|
||||
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/nextjs/5-file-uploads">
|
||||
File upload and management
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Complete the [User Authentication part](/getting-started/tutorials/nextjs/3-user-authentication) first
|
||||
- The project from the previous part set up and running
|
||||
|
||||
## What You'll Build
|
||||
|
||||
By the end of this part, you'll have:
|
||||
- **GraphQL queries and mutations** for complete CRUD operations
|
||||
- **Database schema** with proper relationships and constraints
|
||||
- **User permissions** for secure data access control
|
||||
- **Next.js components** using server/client patterns that interact with GraphQL endpoint
|
||||
- **Server actions** for secure data mutations
|
||||
- **Server components** for efficient data fetching
|
||||
|
||||
## Step-by-Step Guide
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
|
||||
### Create the To-Dos Table
|
||||
|
||||
First, we'll perform the database changes to set up the todos table with proper schema and relationships to users.
|
||||
|
||||
In your Nhost project dashboard:
|
||||
1. Navigate to **Database**
|
||||
2. Click on the SQL Editor
|
||||
|
||||
Enter the following SQL:
|
||||
|
||||
<Tabs>
|
||||
|
||||
<Tab title="SQL">
|
||||
|
||||
```sql
|
||||
CREATE TABLE public.todos (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
created_at timestamptz DEFAULT now() NOT NULL,
|
||||
updated_at timestamptz DEFAULT now() NOT NULL,
|
||||
title text NOT NULL,
|
||||
details text,
|
||||
completed bool DEFAULT false NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (user_id) REFERENCES auth.users (id) ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
|
||||
CREATE TRIGGER update_todos_updated_at
|
||||
BEFORE UPDATE ON public.todos
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
```
|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab title="UI">
|
||||

|
||||
</Tab>
|
||||
|
||||
|
||||
</Tabs>
|
||||
|
||||
<Warning>
|
||||
Please make sure to enable **Track this** so that the new table todos is available through the auto-generated APIs
|
||||
</Warning>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Set Up Permissions
|
||||
|
||||
It's now time to set permission rules for the table you just created. With the table `todos` selected, click on **…**, followed by **Edit Permissions**.
|
||||
You will set permissions for the **user** role and actions **insert**, **select**, **update**, and **delete**.
|
||||
|
||||
<Tabs>
|
||||
|
||||
<Tab title="Insert">
|
||||
|
||||
When inserting permissions we are only allowing users to set the `title`, `details`, and `completed` columns as the rest of the columns are set automatically by the backend. The `user_id` column is configured as a preset to the currently authenticated user's ID using the `X-Hasura-User-Id` session variable. This ensures that each todo is associated with the user who created it.
|
||||
|
||||
|
||||

|
||||
</Tab>
|
||||
|
||||
<Tab title="Select">
|
||||
|
||||
For selecting (reading) todos, we are allowing to read all columns but only for rows where the `user_id` matches the authenticated user's ID. This ensures that users can only see their own todos.
|
||||
|
||||

|
||||
</Tab>
|
||||
|
||||
<Tab title="Update">
|
||||
|
||||
When updating todos, we are allowing users to modify the `title`, `details`, and `completed` columns but only for rows where the `user_id` matches their own ID. This prevents users from modifying todos that do not belong to them.
|
||||
|
||||

|
||||
</Tab>
|
||||
|
||||
<Tab title="Delete">
|
||||
|
||||
For deleting todos, we are allowing users to delete rows only where the `user_id` matches their own ID. This ensures that users cannot delete todos that belong to other users.
|
||||
|
||||

|
||||
</Tab>
|
||||
|
||||
</Tabs>
|
||||
|
||||
</Step>
|
||||
|
||||
|
||||
<Step>
|
||||
|
||||
### Create the Todos Page System
|
||||
|
||||
Now let's implement the Next.js page system that uses the database we just configured. We'll create a server component for the main page, a client component for the interactive todos interface, and server actions for secure data mutations.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Page Component">
|
||||
|
||||
The main todos page is a **server component** that fetches initial data server-side and renders the todos interface. This component runs on the server and provides the initial state to the client component.
|
||||
|
||||
```tsx src/app/todos/page.tsx
|
||||
import { createNhostClient } from "../../lib/nhost/server";
|
||||
import TodosClient from "./TodosClient";
|
||||
|
||||
// The interfaces below define the structure of our data
|
||||
// They are not strictly necessary but help with type safety
|
||||
|
||||
// Represents a single todo item
|
||||
export interface Todo {
|
||||
id: string;
|
||||
title: string;
|
||||
details: string | null;
|
||||
completed: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
// This matches the GraphQL response structure for fetching todos
|
||||
// Can be used as a generic type on the request method
|
||||
interface GetTodos {
|
||||
todos: Todo[];
|
||||
}
|
||||
|
||||
export default async function TodosPage() {
|
||||
// Fetch initial todos data server-side
|
||||
const nhost = await createNhostClient();
|
||||
const session = nhost.getUserSession();
|
||||
|
||||
let initialTodos: Todo[] = [];
|
||||
let error: string | null = null;
|
||||
|
||||
if (session) {
|
||||
try {
|
||||
// Make GraphQL request to fetch todos using Nhost server client
|
||||
// The query automatically filters by user_id due to Hasura permissions
|
||||
const response = await nhost.graphql.request<GetTodos>({
|
||||
query: `
|
||||
query GetTodos {
|
||||
todos(order_by: { created_at: desc }) {
|
||||
id
|
||||
title
|
||||
details
|
||||
completed
|
||||
created_at
|
||||
updated_at
|
||||
user_id
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
// Check for GraphQL errors in the response body
|
||||
if (response.body.errors) {
|
||||
error = response.body.errors[0]?.message || "Failed to fetch todos";
|
||||
} else {
|
||||
// Extract todos from the GraphQL response data
|
||||
initialTodos = response.body?.data?.todos || [];
|
||||
}
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : "Failed to fetch todos";
|
||||
}
|
||||
}
|
||||
|
||||
return <TodosClient initialTodos={initialTodos} initialError={error} />;
|
||||
}
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="Client Component">
|
||||
|
||||
The todos client component is a **client component** that handles all interactive functionality including form submissions, state management, and user interactions. It receives initial data from the server component and manages the client-side state.
|
||||
|
||||
```tsx src/app/todos/TodosClient.tsx
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import { addTodo, deleteTodo, updateTodo } from "./actions";
|
||||
import type { Todo } from "./page";
|
||||
|
||||
interface TodosClientProps {
|
||||
initialTodos: Todo[];
|
||||
initialError: string | null;
|
||||
}
|
||||
|
||||
export default function TodosClient({
|
||||
initialTodos,
|
||||
initialError,
|
||||
}: TodosClientProps) {
|
||||
const [todos, setTodos] = useState<Todo[]>(initialTodos);
|
||||
const [error, setError] = useState<string | null>(initialError);
|
||||
const [newTodoTitle, setNewTodoTitle] = useState("");
|
||||
const [newTodoDetails, setNewTodoDetails] = useState("");
|
||||
const [editingTodo, setEditingTodo] = useState<Todo | null>(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [expandedTodos, setExpandedTodos] = useState<Set<string>>(new Set());
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const titleId = useId();
|
||||
const detailsId = useId();
|
||||
|
||||
const handleAddTodo = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newTodoTitle.trim()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Call server action to add todo
|
||||
const result = await addTodo({
|
||||
title: newTodoTitle.trim(),
|
||||
details: newTodoDetails.trim() || null,
|
||||
});
|
||||
|
||||
if (result.success && result.todo) {
|
||||
setTodos([result.todo, ...todos]);
|
||||
setNewTodoTitle("");
|
||||
setNewTodoDetails("");
|
||||
setShowAddForm(false);
|
||||
setError(null);
|
||||
} else {
|
||||
setError(result.error || "Failed to add todo");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to add todo");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateTodo = async (
|
||||
id: string,
|
||||
updates: Partial<Pick<Todo, "title" | "details" | "completed">>,
|
||||
) => {
|
||||
try {
|
||||
// Call server action to update todo
|
||||
const result = await updateTodo(id, updates);
|
||||
|
||||
if (result.success && result.todo) {
|
||||
setTodos(
|
||||
todos.map((todo) => (todo.id === id ? (result.todo ?? todo) : todo)),
|
||||
);
|
||||
setEditingTodo(null);
|
||||
setError(null);
|
||||
} else {
|
||||
setError(result.error || "Failed to update todo");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to update todo");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTodo = async (id: string) => {
|
||||
if (!confirm("Are you sure you want to delete this todo?")) return;
|
||||
|
||||
try {
|
||||
// Call server action to delete todo
|
||||
const result = await deleteTodo(id);
|
||||
|
||||
if (result.success) {
|
||||
setTodos(todos.filter((todo) => todo.id !== id));
|
||||
setError(null);
|
||||
} else {
|
||||
setError(result.error || "Failed to delete todo");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to delete todo");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleComplete = async (todo: Todo) => {
|
||||
await handleUpdateTodo(todo.id, { completed: !todo.completed });
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!editingTodo) return;
|
||||
await handleUpdateTodo(editingTodo.id, {
|
||||
title: editingTodo.title,
|
||||
details: editingTodo.details,
|
||||
});
|
||||
};
|
||||
|
||||
const toggleTodoExpansion = (todoId: string) => {
|
||||
const newExpanded = new Set(expandedTodos);
|
||||
if (newExpanded.has(todoId)) {
|
||||
newExpanded.delete(todoId);
|
||||
} else {
|
||||
newExpanded.add(todoId);
|
||||
}
|
||||
setExpandedTodos(newExpanded);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<header className="page-header">
|
||||
<h1 className="page-title">
|
||||
My Todos
|
||||
{!showAddForm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="add-todo-btn"
|
||||
title="Add a new todo"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAddForm && (
|
||||
<div className="todo-form-card">
|
||||
<form onSubmit={handleAddTodo} className="todo-form">
|
||||
<h2 className="form-title">Add New Todo</h2>
|
||||
<div className="form-fields">
|
||||
<div className="field-group">
|
||||
<label htmlFor={titleId}>Title *</label>
|
||||
<input
|
||||
id={titleId}
|
||||
type="text"
|
||||
value={newTodoTitle}
|
||||
onChange={(e) => setNewTodoTitle(e.target.value)}
|
||||
placeholder="What needs to be done?"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="field-group">
|
||||
<label htmlFor={detailsId}>Details</label>
|
||||
<textarea
|
||||
id={detailsId}
|
||||
value={newTodoDetails}
|
||||
onChange={(e) => setNewTodoDetails(e.target.value)}
|
||||
placeholder="Add some details (optional)..."
|
||||
rows={3}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Adding..." : "Add Todo"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowAddForm(false);
|
||||
setNewTodoTitle("");
|
||||
setNewTodoDetails("");
|
||||
}}
|
||||
className="btn btn-secondary"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showAddForm && (
|
||||
<div className="todos-list">
|
||||
{todos.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<svg
|
||||
className="empty-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="empty-title">No todos yet</h3>
|
||||
<p className="empty-description">
|
||||
Create your first todo to get started!
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
todos.map((todo) => (
|
||||
<div
|
||||
key={todo.id}
|
||||
className={`todo-card ${todo.completed ? "completed" : ""}`}
|
||||
>
|
||||
{editingTodo?.id === todo.id ? (
|
||||
<div className="todo-edit">
|
||||
<div className="edit-fields">
|
||||
<div className="field-group">
|
||||
<label htmlFor={`${titleId}-edit`}>Title</label>
|
||||
<input
|
||||
id={`${titleId}-edit`}
|
||||
type="text"
|
||||
value={editingTodo.title}
|
||||
onChange={(e) =>
|
||||
setEditingTodo({
|
||||
...editingTodo,
|
||||
title: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="field-group">
|
||||
<label htmlFor={`${detailsId}-edit`}>Details</label>
|
||||
<textarea
|
||||
id={`${detailsId}-edit`}
|
||||
value={editingTodo.details || ""}
|
||||
onChange={(e) =>
|
||||
setEditingTodo({
|
||||
...editingTodo,
|
||||
details: e.target.value,
|
||||
})
|
||||
}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="edit-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveEdit}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
✓ Save Changes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingTodo(null)}
|
||||
className="btn btn-cancel"
|
||||
>
|
||||
✕ Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="todo-content">
|
||||
<div className="todo-header">
|
||||
<button
|
||||
type="button"
|
||||
className={`todo-title-btn ${todo.completed ? "completed" : ""}`}
|
||||
onClick={() => toggleTodoExpansion(todo.id)}
|
||||
>
|
||||
{todo.title}
|
||||
</button>
|
||||
<div className="todo-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleComplete(todo)}
|
||||
className="action-btn action-btn-complete"
|
||||
title={
|
||||
todo.completed
|
||||
? "Mark as incomplete"
|
||||
: "Mark as complete"
|
||||
}
|
||||
>
|
||||
{todo.completed ? "↶" : "✓"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingTodo(todo)}
|
||||
className="action-btn action-btn-edit"
|
||||
title="Edit todo"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteTodo(todo.id)}
|
||||
className="action-btn action-btn-delete"
|
||||
title="Delete todo"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedTodos.has(todo.id) && (
|
||||
<div className="todo-details">
|
||||
{todo.details && (
|
||||
<div
|
||||
className={`todo-description ${todo.completed ? "completed" : ""}`}
|
||||
>
|
||||
<p>{todo.details}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="todo-meta">
|
||||
<div className="meta-dates">
|
||||
<span className="meta-item">
|
||||
Created:{" "}
|
||||
{new Date(todo.created_at).toLocaleString()}
|
||||
</span>
|
||||
<span className="meta-item">
|
||||
Updated:{" "}
|
||||
{new Date(todo.updated_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
{todo.completed && (
|
||||
<div className="completion-badge">
|
||||
<svg
|
||||
className="completion-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Completed</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="Server Actions">
|
||||
|
||||
Server actions handle all data mutations securely on the server side. They validate permissions, execute GraphQL operations, and return type-safe responses to the client components.
|
||||
|
||||
```tsx src/app/todos/actions.ts
|
||||
"use server";
|
||||
|
||||
import type { ErrorResponse } from "@nhost/nhost-js/auth";
|
||||
import type { FetchError } from "@nhost/nhost-js/fetch";
|
||||
import { createNhostClient } from "../../lib/nhost/server";
|
||||
import type { Todo } from "./page";
|
||||
|
||||
// Response types for server actions
|
||||
type ActionResult<T = void> = {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
todo?: T;
|
||||
};
|
||||
|
||||
// GraphQL response types
|
||||
interface InsertTodoResponse {
|
||||
insert_todos_one: Todo | null;
|
||||
}
|
||||
|
||||
interface UpdateTodoResponse {
|
||||
update_todos_by_pk: Todo | null;
|
||||
}
|
||||
|
||||
interface DeleteTodoResponse {
|
||||
delete_todos_by_pk: { id: string } | null;
|
||||
}
|
||||
|
||||
export async function addTodo(data: {
|
||||
title: string;
|
||||
details: string | null;
|
||||
}): Promise<ActionResult<Todo>> {
|
||||
const { title, details } = data;
|
||||
|
||||
if (!title.trim()) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Title is required",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const nhost = await createNhostClient();
|
||||
const session = nhost.getUserSession();
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Not authenticated",
|
||||
};
|
||||
}
|
||||
|
||||
// Execute GraphQL mutation to insert a new todo
|
||||
// user_id is automatically set by Hasura based on JWT token
|
||||
const response = await nhost.graphql.request<InsertTodoResponse>({
|
||||
query: `
|
||||
mutation InsertTodo($title: String!, $details: String) {
|
||||
insert_todos_one(object: { title: $title, details: $details }) {
|
||||
id
|
||||
title
|
||||
details
|
||||
completed
|
||||
created_at
|
||||
updated_at
|
||||
user_id
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
title: title.trim(),
|
||||
details: details?.trim() || null,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.body.errors) {
|
||||
return {
|
||||
success: false,
|
||||
error: response.body.errors[0]?.message || "Failed to add todo",
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.body?.data?.insert_todos_one) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Failed to add todo",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
todo: response.body.data.insert_todos_one,
|
||||
};
|
||||
} catch (err) {
|
||||
const error = err as FetchError<ErrorResponse>;
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to add todo: ${error.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateTodo(
|
||||
id: string,
|
||||
updates: Partial<Pick<Todo, "title" | "details" | "completed">>,
|
||||
): Promise<ActionResult<Todo>> {
|
||||
if (!id) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Todo ID is required",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const nhost = await createNhostClient();
|
||||
const session = nhost.getUserSession();
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Not authenticated",
|
||||
};
|
||||
}
|
||||
|
||||
// Execute GraphQL mutation to update an existing todo by primary key
|
||||
// Hasura permissions ensure users can only update their own todos
|
||||
const response = await nhost.graphql.request<UpdateTodoResponse>({
|
||||
query: `
|
||||
mutation UpdateTodo($id: uuid!, $updates: todos_set_input!) {
|
||||
update_todos_by_pk(pk_columns: { id: $id }, _set: $updates) {
|
||||
id
|
||||
title
|
||||
details
|
||||
completed
|
||||
created_at
|
||||
updated_at
|
||||
user_id
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
id,
|
||||
updates,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.body.errors) {
|
||||
return {
|
||||
success: false,
|
||||
error: response.body.errors[0]?.message || "Failed to update todo",
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.body?.data?.update_todos_by_pk) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Failed to update todo",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
todo: response.body.data.update_todos_by_pk,
|
||||
};
|
||||
} catch (err) {
|
||||
const error = err as FetchError<ErrorResponse>;
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to update todo: ${error.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTodo(id: string): Promise<ActionResult> {
|
||||
if (!id) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Todo ID is required",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const nhost = await createNhostClient();
|
||||
const session = nhost.getUserSession();
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Not authenticated",
|
||||
};
|
||||
}
|
||||
|
||||
// Execute GraphQL mutation to delete a todo by primary key
|
||||
// Hasura permissions ensure users can only delete their own todos
|
||||
const response = await nhost.graphql.request<DeleteTodoResponse>({
|
||||
query: `
|
||||
mutation DeleteTodo($id: uuid!) {
|
||||
delete_todos_by_pk(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.body.errors) {
|
||||
return {
|
||||
success: false,
|
||||
error: response.body.errors[0]?.message || "Failed to delete todo",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (err) {
|
||||
const error = err as FetchError<ErrorResponse>;
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to delete todo: ${error.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Update Navigation Component
|
||||
|
||||
Add the todos page to your application navigation by updating the Navigation component to include a link to the todos page.
|
||||
|
||||
```tsx src/components/Navigation.tsx lines highlight={23-25}
|
||||
import Link from "next/link";
|
||||
import { createNhostClient } from "../lib/nhost/server";
|
||||
import SignOutButton from "./SignOutButton";
|
||||
|
||||
export default async function Navigation() {
|
||||
const nhost = await createNhostClient();
|
||||
const session = nhost.getUserSession();
|
||||
|
||||
return (
|
||||
<nav className="navigation">
|
||||
<div className="nav-container">
|
||||
<Link href="/" className="nav-logo">
|
||||
Nhost Next.js Demo
|
||||
</Link>
|
||||
|
||||
<div className="nav-links">
|
||||
<Link href="/" className="nav-link">
|
||||
Home
|
||||
</Link>
|
||||
|
||||
{session ? (
|
||||
<>
|
||||
<Link href="/todos" className="nav-link">
|
||||
Todos
|
||||
</Link>
|
||||
<Link href="/profile" className="nav-link">
|
||||
Profile
|
||||
</Link>
|
||||
<SignOutButton />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/signin" className="nav-link">
|
||||
Sign In
|
||||
</Link>
|
||||
<Link href="/signup" className="nav-link">
|
||||
Sign Up
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Test Your Complete Application
|
||||
|
||||
Run your Next.js application and test all the functionality:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Things to try out:
|
||||
|
||||
1. **Server-Side Rendering**: Notice how the todos are loaded server-side on initial page load, providing faster initial rendering
|
||||
2. **Authentication Integration**: Try signing in and out and see how the Todos page is only available when authenticated through middleware protection
|
||||
3. **CRUD Operations**: Create, view, edit, complete, and delete todos. Notice how server actions handle mutations while maintaining type safety
|
||||
4. **Multi-User Isolation**: Open the application in another browser or incognito window, sign in with a different account and verify that you cannot see or modify todos from the first account
|
||||
5. **Real-time Updates**: Unlike client-only React apps, changes will be persisted immediately through server actions and reflected in the optimistic UI updates
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Key Features Implemented
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Database Schema" icon="database">
|
||||
Properly designed todos table with constraints, indexes, and automatic timestamp updates for optimal performance.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="GraphQL API" icon="webhook">
|
||||
Auto-generated GraphQL API with queries and mutations for full CRUD operations on todos.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Row-Level Security" icon="shield-check">
|
||||
Comprehensive permissions ensuring users can only access their own todos through all GraphQL operations.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Server/Client Architecture" icon="server">
|
||||
Next.js App Router patterns with server components for data fetching, client components for interactivity, and server actions for mutations.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Server-Side Data Fetching" icon="database">
|
||||
Initial todos loaded server-side for improved performance and SEO, with client-side state management for optimal user experience.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Type-Safe Server Actions" icon="shield">
|
||||
Secure server-side mutations with comprehensive error handling and type safety throughout the data flow.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Rich Interface" icon="sparkles">
|
||||
Expandable todo items, inline editing, completion status, and detailed timestamps with responsive design.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
822
docs/getting-started/tutorials/nextjs/5-file-uploads.mdx
Normal file
@@ -0,0 +1,822 @@
|
||||
---
|
||||
title: File Uploads in Next.js
|
||||
description: Learn how to implement file upload functionality with storage buckets and permissions while building a complete file management system with Nhost and Next.js
|
||||
sidebarTitle: "File Uploads"
|
||||
icon: upload
|
||||
---
|
||||
|
||||
This part builds upon the previous GraphQL operations part by demonstrating how to implement file upload functionality with proper storage permissions. You'll learn how to create storage buckets, configure upload permissions, and implement complete file management operations in a Next.js application using server and client components.
|
||||
|
||||
<Info>
|
||||
This is **Part 5** in the Full-Stack Next.js Development with Nhost series. This part focuses on file storage, upload operations, and permission-based file access control in a production application using Next.js App Router patterns.
|
||||
</Info>
|
||||
|
||||
## Full-Stack Next.js Development with Nhost
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/nextjs/1-introduction">
|
||||
Set up your Nhost project
|
||||
</Card>
|
||||
|
||||
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/nextjs/2-protected-routes">
|
||||
Route protection basics
|
||||
</Card>
|
||||
|
||||
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/nextjs/3-user-authentication">
|
||||
Complete auth flow
|
||||
</Card>
|
||||
|
||||
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/nextjs/4-graphql-operations">
|
||||
CRUD operations with GraphQL
|
||||
</Card>
|
||||
|
||||
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/nextjs/5-file-uploads">
|
||||
**Current** - File upload and management
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Complete the [GraphQL Operations part](/getting-started/tutorials/nextjs/4-graphql-operations) first
|
||||
- The project from the previous part set up and running
|
||||
|
||||
## What You'll Build
|
||||
|
||||
By the end of this part, you'll have:
|
||||
- A **personal bucket** so users can upload their own private files
|
||||
- **File upload functionality**
|
||||
- **File management interface** for viewing and deleting files
|
||||
- **Security permissions** ensuring users can only access their own files
|
||||
|
||||
## Step-by-Step Guide
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
|
||||
### Create a Personal Storage Bucket
|
||||
|
||||
First, we'll create a storage bucket where users can upload their personal files.
|
||||
|
||||
In your Nhost project dashboard:
|
||||
1. Navigate to **Database**
|
||||
2. Change to **schema.storage**, then buckets
|
||||
3. Now click on `+ Insert` on the top right corner.
|
||||
4. As id set `personal`, leave the rest of the fields blank and click on Insert at the bottom
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Configure Storage Permissions
|
||||
|
||||
Now we need to set up permissions for the storage bucket to ensure the `user` role can only upload, view, and delete their own files.
|
||||
|
||||
<Tabs>
|
||||
|
||||
<Tab title="Upload">
|
||||
|
||||
To upload files we need to grant permissions to insert on the table `storage.files`. Because we want to allow uploading files only to the `personal` bucket we will be using the `bucket_id eq personal` as a custom check. In addition, we are configuring a preset `uploaded_by_user_id = X-Hasura-User-id`, this will automatically extract the user_id from the session and set the column accordingly. Then we can use this in other permissions to allow downloading files and deleting them.
|
||||
|
||||

|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab title="Download">
|
||||
|
||||
To download files users need to be able to query those files. To make sure users can only download files they uploaded we will be leveraging the column `uploaded_by_user_id` column from before and the `bucket_id``.
|
||||
|
||||

|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab title="Delete">
|
||||
|
||||
Similarly to downloading files, to delete files users need to be able to delete rows from the `storage.files` table. Again we will use the `uploaded_by_user_id` column and the `bucket_id` to make sure users can only delete their own files.
|
||||
|
||||

|
||||
|
||||
</Tab>
|
||||
|
||||
</Tabs>
|
||||
|
||||
<Info>
|
||||
You can read more about storage permissions [here](/products/storage/overview#permissions)
|
||||
</Info>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Create the File Upload System
|
||||
|
||||
Now let's implement the Next.js file upload functionality using server actions, API routes, and client components. We'll use server actions for upload and delete operations, and API routes for file viewing and downloading.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Server Actions">
|
||||
|
||||
First, let's create server actions to handle file upload and delete operations. Server actions run on the server and provide a secure way to handle file operations.
|
||||
|
||||
```tsx src/app/files/actions.ts
|
||||
"use server";
|
||||
|
||||
import type { FileMetadata } from "@nhost/nhost-js/storage";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { createNhostClient } from "../../lib/nhost/server";
|
||||
|
||||
export interface ActionResult<T> {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
export interface UploadFileData {
|
||||
file: FileMetadata;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface DeleteFileData {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export async function uploadFileAction(
|
||||
formData: FormData,
|
||||
): Promise<ActionResult<UploadFileData>> {
|
||||
try {
|
||||
const nhost = await createNhostClient();
|
||||
const file = formData.get("file") as File;
|
||||
|
||||
if (!file) {
|
||||
return { success: false, error: "Please select a file to upload" };
|
||||
}
|
||||
|
||||
const response = await nhost.storage.uploadFiles({
|
||||
"bucket-id": "personal",
|
||||
"file[]": [file],
|
||||
});
|
||||
|
||||
const uploadedFile = response.body.processedFiles?.[0];
|
||||
if (!uploadedFile) {
|
||||
return { success: false, error: "Failed to upload file" };
|
||||
}
|
||||
|
||||
revalidatePath("/files");
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
file: uploadedFile,
|
||||
message: "File uploaded successfully!",
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "An unknown error occurred";
|
||||
return { success: false, error: `Failed to upload file: ${message}` };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteFileAction(
|
||||
fileId: string,
|
||||
fileName: string,
|
||||
): Promise<ActionResult<DeleteFileData>> {
|
||||
try {
|
||||
const nhost = await createNhostClient();
|
||||
|
||||
if (!fileId) {
|
||||
return { success: false, error: "File ID is required" };
|
||||
}
|
||||
|
||||
await nhost.storage.deleteFile(fileId);
|
||||
|
||||
revalidatePath("/files");
|
||||
return {
|
||||
success: true,
|
||||
data: { message: `${fileName} deleted successfully` },
|
||||
};
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "An unknown error occurred";
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to delete ${fileName}: ${message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="API Routes">
|
||||
|
||||
Create an API route to handle file viewing and downloading. This route will fetch the file from Nhost storage and return it with appropriate headers to the client.
|
||||
|
||||
```tsx src/app/files/download/[fileId]/route.ts
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { createNhostClient } from "../../../../lib/nhost/server";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ fileId: string }> },
|
||||
) {
|
||||
try {
|
||||
const nhost = await createNhostClient();
|
||||
const { fileId } = await params;
|
||||
const fileName = request.nextUrl.searchParams.get("fileName") || "file";
|
||||
const download = request.nextUrl.searchParams.get("download") === "true";
|
||||
|
||||
if (!fileId) {
|
||||
return NextResponse.json(
|
||||
{ error: "File ID is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const response = await nhost.storage.getFile(fileId);
|
||||
|
||||
if (!response.body) {
|
||||
return NextResponse.json({ error: "File not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Get the file content as an array buffer
|
||||
const arrayBuffer = await response.body.arrayBuffer();
|
||||
|
||||
// Determine content disposition based on download parameter
|
||||
const contentDisposition = download
|
||||
? `attachment; filename="${fileName}"`
|
||||
: `inline; filename="${fileName}"`;
|
||||
|
||||
// Create the response with appropriate headers
|
||||
return new NextResponse(arrayBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": response.body.type || "application/octet-stream",
|
||||
"Content-Disposition": contentDisposition,
|
||||
"Content-Length": arrayBuffer.byteLength.toString(),
|
||||
"Cache-Control":
|
||||
response.headers.get("Cache-Control") ||
|
||||
"public, max-age=31536000, immutable",
|
||||
Etag: response.headers.get("ETag") || "",
|
||||
"Last-Modified": response.headers.get("Last-Modified") || "",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "An unknown error occurred";
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to access file: ${message}` },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="Page Component">
|
||||
|
||||
The main files page is a **server component** that fetches the user's files on the server and renders the file management interface. This component runs on the server and can directly access the Nhost client for data fetching.
|
||||
|
||||
```tsx src/app/files/page.tsx
|
||||
import type { FileMetadata } from "@nhost/nhost-js/storage";
|
||||
import { createNhostClient } from "../../lib/nhost/server";
|
||||
import FilesClient from "./FilesClient";
|
||||
|
||||
interface GetFilesResponse {
|
||||
files: FileMetadata[];
|
||||
}
|
||||
|
||||
export default async function FilesPage() {
|
||||
const nhost = await createNhostClient();
|
||||
|
||||
// Fetch files on the server
|
||||
let files: FileMetadata[] = [];
|
||||
let error: string | null = null;
|
||||
|
||||
try {
|
||||
// Use GraphQL to fetch files from the storage system
|
||||
// Files are automatically filtered by user permissions
|
||||
const response = await nhost.graphql.request<GetFilesResponse>({
|
||||
query: `query GetFiles {
|
||||
files {
|
||||
id
|
||||
name
|
||||
size
|
||||
mimeType
|
||||
bucketId
|
||||
uploadedByUserId
|
||||
}
|
||||
}`,
|
||||
});
|
||||
|
||||
if (response.body.errors) {
|
||||
throw new Error(
|
||||
response.body.errors[0]?.message || "Failed to fetch files",
|
||||
);
|
||||
}
|
||||
|
||||
files = response.body.data?.files || [];
|
||||
} catch (err) {
|
||||
error = `Failed to load files: ${err instanceof Error ? err.message : "Unknown error"}`;
|
||||
console.error("Error fetching files:", err);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<header className="page-header">
|
||||
<h1 className="page-title">File Upload</h1>
|
||||
</header>
|
||||
|
||||
{/* Pass the server-fetched files to the client component */}
|
||||
<FilesClient initialFiles={files} serverError={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="Client Component">
|
||||
|
||||
The files client component is a **client component** that handles all user interactions including file uploads, viewing, and deletion. It uses server actions for upload and delete operations, and API routes for file viewing and downloading.
|
||||
|
||||
```tsx src/app/files/FilesClient.tsx
|
||||
"use client";
|
||||
|
||||
import type { FileMetadata } from "@nhost/nhost-js/storage";
|
||||
import { useRef, useState, useTransition } from "react";
|
||||
import { deleteFileAction, uploadFileAction } from "./actions";
|
||||
|
||||
interface FilesClientProps {
|
||||
initialFiles: FileMetadata[];
|
||||
serverError: string | null;
|
||||
}
|
||||
|
||||
interface DeleteStatus {
|
||||
message: string;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
|
||||
const sizes: string[] = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
const i: number = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
|
||||
return `${parseFloat((bytes / 1024 ** i).toFixed(2))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export default function FilesClient({
|
||||
initialFiles,
|
||||
serverError,
|
||||
}: FilesClientProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [_isPending, startTransition] = useTransition();
|
||||
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [uploading, setUploading] = useState<boolean>(false);
|
||||
const [uploadResult, setUploadResult] = useState<FileMetadata | null>(null);
|
||||
const [error, setError] = useState<string | null>(serverError);
|
||||
const [files, setFiles] = useState<FileMetadata[]>(initialFiles);
|
||||
const [viewingFile, setViewingFile] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const [deleteStatus, setDeleteStatus] = useState<DeleteStatus | null>(null);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
setSelectedFile(file);
|
||||
setError(null);
|
||||
setUploadResult(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async (): Promise<void> => {
|
||||
if (!selectedFile) {
|
||||
setError("Please select a file to upload");
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", selectedFile);
|
||||
|
||||
const result = await uploadFileAction(formData);
|
||||
|
||||
if (result.success && result.data) {
|
||||
const file = result.data.file;
|
||||
setUploadResult(file);
|
||||
|
||||
// Clear the form
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
|
||||
// Update the files list
|
||||
setFiles((prevFiles) => [file, ...prevFiles]);
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
setUploadResult(null);
|
||||
}, 3000);
|
||||
} else {
|
||||
setError(result.error || "Failed to upload file");
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = (err as Error).message || "An unknown error occurred";
|
||||
setError(`Failed to upload file: ${message}`);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleViewFile = async (
|
||||
fileId: string,
|
||||
fileName: string,
|
||||
mimeType: string,
|
||||
): Promise<void> => {
|
||||
setViewingFile(fileId);
|
||||
|
||||
try {
|
||||
// Handle different file types appropriately
|
||||
if (
|
||||
mimeType.startsWith("image/") ||
|
||||
mimeType === "application/pdf" ||
|
||||
mimeType.startsWith("text/") ||
|
||||
mimeType.startsWith("video/") ||
|
||||
mimeType.startsWith("audio/")
|
||||
) {
|
||||
// Use download route for viewable files (inline viewing)
|
||||
const viewUrl = `/files/download/${fileId}?fileName=${encodeURIComponent(fileName)}`;
|
||||
window.open(viewUrl, "_blank");
|
||||
} else {
|
||||
// Use download route for downloads (force download)
|
||||
const downloadUrl = `/files/download/${fileId}?fileName=${encodeURIComponent(fileName)}&download=true`;
|
||||
const link = document.createElement("a");
|
||||
link.href = downloadUrl;
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// Show download confirmation
|
||||
const newWindow = window.open("", "_blank", "width=400,height=200");
|
||||
if (newWindow) {
|
||||
newWindow.document.documentElement.innerHTML = `
|
||||
<head>
|
||||
<title>File Download</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; padding: 20px; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h3>Downloading: ${fileName}</h3>
|
||||
<p>Your download has started. You can close this window.</p>
|
||||
</body>
|
||||
`;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const message = (err as Error).message || "An unknown error occurred";
|
||||
setError(`Failed to view file: ${message}`);
|
||||
console.error("Error viewing file:", err);
|
||||
} finally {
|
||||
setViewingFile(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFile = async (fileId: string): Promise<void> => {
|
||||
if (!fileId || deleting) return;
|
||||
|
||||
setDeleting(fileId);
|
||||
setError(null);
|
||||
setDeleteStatus(null);
|
||||
|
||||
const fileToDelete = files.find((file) => file.id === fileId);
|
||||
const fileName = fileToDelete?.name || "File";
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await deleteFileAction(fileId, fileName);
|
||||
|
||||
if (result.success && result.data) {
|
||||
setDeleteStatus({
|
||||
message: result.data.message,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
// Remove from local state
|
||||
setFiles(files.filter((file) => file.id !== fileId));
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
setDeleteStatus(null);
|
||||
}, 3000);
|
||||
} else {
|
||||
setDeleteStatus({
|
||||
message: result.error || `Failed to delete ${fileName}`,
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const message = (err as Error).message || "An unknown error occurred";
|
||||
setDeleteStatus({
|
||||
message: `Failed to delete ${fileName}: ${message}`,
|
||||
isError: true,
|
||||
});
|
||||
console.error("Error deleting file:", err);
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="form-card">
|
||||
<h2 className="form-title">Upload a File</h2>
|
||||
|
||||
<div className="field-group">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: "1px",
|
||||
height: "1px",
|
||||
padding: 0,
|
||||
margin: "-1px",
|
||||
overflow: "hidden",
|
||||
clip: "rect(0,0,0,0)",
|
||||
border: 0,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary file-upload-btn"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
role="img"
|
||||
aria-label="Upload file"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
<p>Click to select a file</p>
|
||||
{selectedFile && (
|
||||
<p className="file-upload-info">
|
||||
{selectedFile.name} ({formatFileSize(selectedFile.size)})
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
{uploadResult && (
|
||||
<div className="success-message">File uploaded successfully!</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUpload}
|
||||
disabled={!selectedFile || uploading}
|
||||
className="btn btn-primary"
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{uploading ? "Uploading..." : "Upload File"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="form-card">
|
||||
<h2 className="form-title">Your Files</h2>
|
||||
|
||||
{deleteStatus && (
|
||||
<div
|
||||
className={
|
||||
deleteStatus.isError ? "error-message" : "success-message"
|
||||
}
|
||||
>
|
||||
{deleteStatus.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{files.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<svg
|
||||
className="empty-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="empty-title">No files yet</h3>
|
||||
<p className="empty-description">
|
||||
Upload your first file to get started!
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table className="file-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Size</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{files.map((file) => (
|
||||
<tr key={file.id}>
|
||||
<td className="file-name">{file.name}</td>
|
||||
<td className="file-meta">{file.mimeType}</td>
|
||||
<td className="file-meta">
|
||||
{formatFileSize(file.size || 0)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="file-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleViewFile(
|
||||
file.id || "unknown",
|
||||
file.name || "unknown",
|
||||
file.mimeType || "unknown",
|
||||
)
|
||||
}
|
||||
disabled={viewingFile === file.id}
|
||||
className="action-btn action-btn-edit"
|
||||
title="View File"
|
||||
>
|
||||
{viewingFile === file.id ? "⏳" : "👁️"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteFile(file.id || "unknown")}
|
||||
disabled={deleting === file.id}
|
||||
className="action-btn action-btn-delete"
|
||||
title="Delete File"
|
||||
>
|
||||
{deleting === file.id ? "⏳" : "🗑️"}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Update Navigation Component
|
||||
|
||||
Add a link to the files page in the server-side navigation component.
|
||||
|
||||
```tsx src/components/Navigation.tsx lines highlight={26-28}
|
||||
import Link from "next/link";
|
||||
import { createNhostClient } from "../lib/nhost/server";
|
||||
import SignOutButton from "./SignOutButton";
|
||||
|
||||
export default async function Navigation() {
|
||||
const nhost = await createNhostClient();
|
||||
const session = nhost.getUserSession();
|
||||
|
||||
return (
|
||||
<nav className="navigation">
|
||||
<div className="nav-container">
|
||||
<Link href="/" className="nav-logo">
|
||||
Nhost Next.js Demo
|
||||
</Link>
|
||||
|
||||
<div className="nav-links">
|
||||
<Link href="/" className="nav-link">
|
||||
Home
|
||||
</Link>
|
||||
|
||||
{session ? (
|
||||
<>
|
||||
<Link href="/todos" className="nav-link">
|
||||
Todos
|
||||
</Link>
|
||||
<Link href="/files" className="nav-link">
|
||||
Files
|
||||
</Link>
|
||||
<Link href="/profile" className="nav-link">
|
||||
Profile
|
||||
</Link>
|
||||
<SignOutButton />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/signin" className="nav-link">
|
||||
Sign In
|
||||
</Link>
|
||||
<Link href="/signup" className="nav-link">
|
||||
Sign Up
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Test Your File Upload System
|
||||
|
||||
Run your Next.js development server and test all the functionality:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Things to try out:
|
||||
|
||||
1. **Server-Side Protection**: Try accessing `/files` while logged out - the middleware will redirect you to the home page.
|
||||
2. **File Upload Flow**: Sign in and navigate to the Files page using the navigation link. The server component will fetch your existing files, and the client component will handle uploads.
|
||||
3. **Upload Different File Types**: Upload various file types (images, documents, PDFs, etc.) to test the file type handling.
|
||||
4. **View and Delete Files**: Test the view functionality for different file types - images and PDFs will open in new tabs, while other files will download.
|
||||
5. **User Isolation**: Sign in with different accounts to verify users can only see their own files due to storage permissions.
|
||||
6. **Server-Side Rendering**: Notice how your file list loads immediately on page refresh since it's fetched on the server, unlike client-only React apps.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Key Features Implemented
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Server/Client Architecture" icon="server">
|
||||
Clear separation between server components for data fetching and client components for user interactions, following Next.js App Router best practices.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Storage Bucket & Permissions" icon="bucket">
|
||||
Dedicated personal storage bucket with proper configuration for user file isolation, leveraging Nhost's permission system.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Server-Side Data Fetching" icon="database">
|
||||
Files are fetched on the server and passed to client components, providing immediate data on page load without loading states.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="File Upload Interface" icon="upload">
|
||||
User-friendly upload interface with file selection, preview, and progress feedback using client-side interactions.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="File Management" icon="folder">
|
||||
Complete file listing with metadata, viewing capabilities, and deletion functionality with proper state management.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="File Type Handling" icon="file">
|
||||
Intelligent handling of different file types with appropriate viewing/download behavior for various media types.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Route Protection" icon="shield">
|
||||
Automatic route protection through Next.js middleware, ensuring only authenticated users can access file upload functionality.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Error Handling" icon="triangle-exclamation">
|
||||
Comprehensive error handling with server-side error detection and client-side user feedback for all operations.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
@@ -1,497 +0,0 @@
|
||||
---
|
||||
title: Build a Todo Manager with React
|
||||
description: Learn how to use Nhost with React
|
||||
sidebarTitle: React
|
||||
icon: react
|
||||
---
|
||||
|
||||
In this tutorial, you will build a simple **Todo Manager** application with Nhost and React. Along the way you will interact with the Database, Authentication, and Storage services.
|
||||
|
||||
The Todo Manager will allow users to see public `todos` and sign in using a Magic Link to manage their own `todos` with attachments.
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="Database">
|
||||
To store todos
|
||||
</Card>
|
||||
|
||||
<Card title="Auth">
|
||||
To sign in users
|
||||
</Card>
|
||||
|
||||
<Card title="Storage">
|
||||
To store attachments
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
|
||||
## Setup Nhost Backend
|
||||
|
||||
In this section, you will create and setup your first Nhost project.
|
||||
|
||||
### Create project
|
||||
|
||||
Create a new project in the [Nhost Dashboard](https://app.nhost.io).
|
||||
|
||||
Enter the details for your project and wait a couple of minutes while Nhost provisions your backend infrastructure:
|
||||
|
||||
- Dedicated PostgreSQL
|
||||
- Realtime APIs over your data
|
||||
- Authentication for managing your users
|
||||
- Storage for handling files
|
||||
|
||||
### Create table `todos`
|
||||
|
||||
On the project's dashboard, navigate to **Database** and create a new table called `todos`.
|
||||
|
||||

|
||||
|
||||
You can either copy and paste the following SQL into the SQL Editor, **Database -> SQL Editor**, or manually create the table by clicking on **New Table**.
|
||||
|
||||
|
||||
<Tabs>
|
||||
<Tab title="SQL Editor">
|
||||
Copy and paste the following SQL into the SQL Editor and press **Run**.
|
||||
|
||||
<Note>Please make sure to enable **Track this** so that the new table `todos` is available through the auto-generated APIs</Note>
|
||||
|
||||
```sql SQL
|
||||
CREATE TABLE public.todos (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
created_at timestamptz DEFAULT now() NOT NULL,
|
||||
updated_at timestamptz DEFAULT now() NOT NULL,
|
||||
title text NOT NULL,
|
||||
completed bool DEFAULT 'false' NOT NULL,
|
||||
file_id uuid,
|
||||
user_id uuid NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (file_id) REFERENCES storage.files (id) ON UPDATE SET NULL ON DELETE SET NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES auth.users (id) ON UPDATE SET NULL ON DELETE SET NULL
|
||||
);
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="UI">
|
||||
Click on **New Table** and fill in the details for the `todos` table as shown.
|
||||
|
||||

|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
You should now see a new table called `todos` on the left panel, below **New Table**.
|
||||
|
||||
### Set permissions for todos
|
||||
|
||||
It's now time to set permission rules for the table you just created. With the table `todos` selected, click on **...**, followed by **Edit Permissions**.
|
||||
|
||||
You will set permissions for the `user` role and actions `insert`, `select`, `update`, and `delete`.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="insert">
|
||||
Click on the right cell for the `user` role and action `insert` and set permissions as follows:
|
||||

|
||||
</Tab>
|
||||
<Tab title="select">
|
||||
Click on the right cell for the `user` role and action `select` and set permissions as follows:
|
||||

|
||||
</Tab>
|
||||
<Tab title="update">
|
||||
Click on the right cell for the `user` role and action `update` and set permissions as follows:
|
||||

|
||||
</Tab>
|
||||
<Tab title="delete">
|
||||
Click on the right cell for the `user` role and action `delete` and set permissions as follows:
|
||||

|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Set permissions for files
|
||||
|
||||
The `files` table is managed by Nhost and is defined on the `storage` schema. Click on the dropdown right next to `schema.public` and choose `schema.storage`.
|
||||
|
||||
With the `files` table selected, click on **...**, followed by **Edit Permissions**.
|
||||
|
||||
As before, we want to set permissions for the `user` role and actions `insert`, `select`, `delete`.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="insert">
|
||||
Click on the right cell for the `user` role and action `insert` and set permissions as follows:
|
||||

|
||||
</Tab>
|
||||
<Tab title="select">
|
||||
Click on the right cell for the `user` role and action `select` and set permissions as follows:
|
||||

|
||||
</Tab>
|
||||
<Tab title="delete">
|
||||
Click on the right cell for the `user` role and action `delete` and set permissions as follows:
|
||||

|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Enable Sign In with Magic Link
|
||||
|
||||
To enable Magic Links, navigate to your project's **Settings -> Sign-In Methods**, toggle Magic Link, and save.
|
||||
|
||||
### Recap
|
||||
|
||||
<Steps>
|
||||
<Step title="Nhost project created">
|
||||
</Step>
|
||||
|
||||
<Step title="Database todos created">
|
||||
</Step>
|
||||
|
||||
<Step title="Permissions set for todos and files">
|
||||
</Step>
|
||||
|
||||
<Step title="Magic Link enabled">
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Setup React Application
|
||||
|
||||
Now that we have Nhost configured, let's move on to setup the React application and the Nhost client.
|
||||
|
||||
### Create React Application
|
||||
|
||||
Run the following command in your terminal to create a React application using Vite.
|
||||
|
||||
```bash Terminal
|
||||
npm create vite@latest nhost-react -- --template react
|
||||
```
|
||||
|
||||
### Install Nhost React package
|
||||
|
||||
To install Nhost's React package, run the following command.
|
||||
|
||||
```bash Terminal
|
||||
cd nhost-react && npm install @nhost/react
|
||||
```
|
||||
|
||||
#### Configure the Nhost Client
|
||||
|
||||
Create a new file, `./src/lib/nhost.js`, with the following code to create a Nhost client. Replace `<SUBDOMAIN>` and `<REGION>` with the values from the project created earlier.
|
||||
|
||||
```ts ./src/lib/nhost.ts
|
||||
import { NhostClient } from "@nhost/react";
|
||||
|
||||
export const nhost = new NhostClient({
|
||||
subdomain: "<SUBDOMAIN>",
|
||||
region: "<REGION>"
|
||||
});
|
||||
```
|
||||
|
||||
<Info>The project's `subdomain` and `region` can be found in the Nhost Dashboard under **Project Info**</Info>
|
||||
|
||||
### Setup Sign In Component
|
||||
|
||||
It is time to setup a new React component to handle the login functionality. Users will be able to sign in using a Magic Link.
|
||||
|
||||
Create a new file `./src/signin.jsx` with the following content:
|
||||
|
||||
```js ./src/signin.jsx
|
||||
import { useState } from 'react'
|
||||
import { useSignInEmailPasswordless } from '@nhost/react'
|
||||
|
||||
export default function SignIn() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [email, setEmail] = useState('')
|
||||
|
||||
const { signInEmailPasswordless, error } = useSignInEmailPasswordless()
|
||||
|
||||
const handleSignIn = async (event) => {
|
||||
event.preventDefault()
|
||||
|
||||
setLoading(true)
|
||||
const { error } = await signInEmailPasswordless(email)
|
||||
|
||||
if (error) {
|
||||
console.error({ error })
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
alert('Magic Link Sent!')
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Todo Manager</h1>
|
||||
<p>powered by Nhost and React</p>
|
||||
<form onSubmit={handleSignIn}>
|
||||
<div>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Your email"
|
||||
value={email}
|
||||
required={true}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button disabled={loading}>
|
||||
{loading ? <span>Loading</span> : <span>Send me a Magic Link!</span>}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p>{error.message}</p>}
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Setup `Todos` Component
|
||||
|
||||
Now that users can sign in, let's move on and create the authenticated page that lists a user's todos and has a form for managing todos with attachments.
|
||||
|
||||
```js ./src/todos.jsx
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNhostClient, useFileUpload } from '@nhost/react'
|
||||
|
||||
const deleteTodo = `
|
||||
mutation($id: uuid!) {
|
||||
delete_todos_by_pk(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
const createTodo = `
|
||||
mutation($title: String!, $file_id: uuid) {
|
||||
insert_todos_one(object: {title: $title, file_id: $file_id}) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
const getTodos = `
|
||||
query {
|
||||
todos {
|
||||
id
|
||||
title
|
||||
file_id
|
||||
completed
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export default function Todos() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [todos, setTodos] = useState([])
|
||||
|
||||
const [todoTitle, setTodoTitle] = useState('')
|
||||
const [todoAttachment, setTodoAttachment] = useState(null)
|
||||
const [fetchAll, setFetchAll] = useState(false)
|
||||
|
||||
const nhostClient = useNhostClient()
|
||||
const { upload } = useFileUpload()
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchTodos() {
|
||||
setLoading(true)
|
||||
const { data, error } = await nhostClient.graphql.request(getTodos)
|
||||
|
||||
if (error) {
|
||||
console.error({ error })
|
||||
return
|
||||
}
|
||||
|
||||
setTodos(data.todos)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
fetchTodos()
|
||||
|
||||
return () => {
|
||||
setFetchAll(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fetchAll])
|
||||
|
||||
const handleCreateTodo = async (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
let todo = { title: todoTitle }
|
||||
if (todoAttachment) {
|
||||
const { id, error } = await upload({
|
||||
file: todoAttachment,
|
||||
name: todoAttachment.name
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error({ error })
|
||||
return
|
||||
}
|
||||
|
||||
todo.file_id = id
|
||||
}
|
||||
|
||||
const { error } = await nhostClient.graphql.request(createTodo, todo)
|
||||
|
||||
if (error) {
|
||||
console.error({ error })
|
||||
}
|
||||
|
||||
setTodoTitle('')
|
||||
setTodoAttachment(null)
|
||||
setFetchAll(true)
|
||||
}
|
||||
|
||||
const handleDeleteTodo = async (id) => {
|
||||
if (!window.confirm('Are you sure you want to delete this TODO?')) {
|
||||
return
|
||||
}
|
||||
|
||||
const todo = todos.find((todo) => todo.id === id)
|
||||
if (todo.file_id) {
|
||||
await nhostClient.storage.delete({ fileId: todo.file_id })
|
||||
}
|
||||
|
||||
const { error } = await nhostClient.graphql.request(deleteTodo, { id })
|
||||
if (error) {
|
||||
console.error({ error })
|
||||
}
|
||||
|
||||
setFetchAll(true)
|
||||
}
|
||||
|
||||
const completeTodo = async (id) => {
|
||||
const { error } = await nhostClient.graphql.request(
|
||||
`
|
||||
mutation($id: uuid!) {
|
||||
update_todos_by_pk(pk_columns: {id: $id}, _set: {completed: true}) {
|
||||
completed
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ id }
|
||||
)
|
||||
|
||||
if (error) {
|
||||
console.error({ error })
|
||||
}
|
||||
|
||||
setFetchAll(true)
|
||||
}
|
||||
|
||||
const openAttachment = async (todo) => {
|
||||
const { presignedUrl, error } = await nhostClient.storage.getPresignedUrl({
|
||||
fileId: todo.file_id
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error({ error })
|
||||
return
|
||||
}
|
||||
|
||||
window.open(presignedUrl.url, '_blank')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="container">
|
||||
<div className="form-section">
|
||||
<h2>Add a new TODO</h2>
|
||||
<form onSubmit={handleCreateTodo}>
|
||||
<div className="input-group">
|
||||
<label htmlFor="title">Title</label>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
value={todoTitle}
|
||||
onChange={(e) => setTodoTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label htmlFor="file">File (optional)</label>
|
||||
<input id="file" type="file" onChange={(e) => setTodoAttachment(e.target.files[0])} />
|
||||
</div>
|
||||
<div className="submit-group">
|
||||
<button type="submit" disabled={!todoTitle}>
|
||||
Add Todo
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="todos-section">
|
||||
{(!loading &&
|
||||
todos.map((todo) => (
|
||||
<div className="todo-item" key={todo.id ?? 0}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={todo.completed}
|
||||
disabled={todo.completed}
|
||||
id={`todo-${todo.id}`}
|
||||
onChange={() => completeTodo(todo.id)}
|
||||
/>
|
||||
{todo.file_id && (
|
||||
<span>
|
||||
<a onClick={() => openAttachment(todo)}> Open Attachment</a>
|
||||
</span>
|
||||
)}
|
||||
<label htmlFor={`todo-${todo.id}`} className="todo-title">
|
||||
{todo.completed && <s>{todo.title}</s>}
|
||||
{!todo.completed && todo.title}
|
||||
</label>
|
||||
<button type="button" onClick={() => handleDeleteTodo(todo.id)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
))) || (
|
||||
<div className="todo-item">
|
||||
<label className="todo-title">Loading...</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sign-out-section">
|
||||
<button type="button" onClick={() => nhostClient.auth.signOut()}>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
With both `SignIn` and `Todos` in place, update `./src/App.jsx` to use the new components:
|
||||
|
||||
```js ./src/App.jsx
|
||||
import './App.css'
|
||||
import { NhostProvider } from '@nhost/react'
|
||||
import { nhost } from './lib/nhost.js'
|
||||
import SignIn from './signin'
|
||||
import Todos from './todos'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
function App() {
|
||||
const [session, setSession] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
setSession(nhost.auth.getSession())
|
||||
|
||||
nhost.auth.onAuthStateChanged((_, session) => {
|
||||
setSession(session)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<NhostProvider nhost={nhost}>
|
||||
{session ? <Todos session={session} /> : <SignIn />}
|
||||
</NhostProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
```
|
||||
|
||||
|
||||
## The End
|
||||
|
||||
Run the Todo Manager with:
|
||||
|
||||
```bash Terminal
|
||||
npm run dev -- --open --port 3000
|
||||
```
|
||||
|
||||
Open your browser on [localhost:3000](localhost:3000) to see your new application in action.
|
||||
|
||||
116
docs/getting-started/tutorials/react/1-introduction.mdx
Normal file
@@ -0,0 +1,116 @@
|
||||
---
|
||||
title: Create Your Nhost Project
|
||||
description: Learn how to create and set up a new Nhost project to get started building your React application
|
||||
sidebarTitle: Create Project
|
||||
icon: plus
|
||||
---
|
||||
|
||||
Welcome to the **Full-Stack React Development with Nhost** series! In this comprehensive tutorial series, you'll build a complete React application with Nhost that demonstrates authentication, database operations, and file management.
|
||||
|
||||
## About This Tutorial Series
|
||||
|
||||
This tutorial series is divided into **5 parts**, each focusing on a specific aspect of building modern web applications with Nhost and React. By the end of the series, you'll have built a fully functional application featuring:
|
||||
|
||||
- **User Authentication** - Complete sign up, sign in, and email verification flow
|
||||
- **Todo Management** - Users can create, update, delete, and mark todos as complete
|
||||
- **File Uploads** - Users can upload and manage files with proper permissions
|
||||
- **Protected Routes** - Secure areas that only authenticated users can access
|
||||
|
||||
<Info>
|
||||
This is **Part 1** in the Full-Stack React Development with Nhost series. This part sets up the foundation by creating your Nhost project and understanding the series structure.
|
||||
</Info>
|
||||
|
||||
## Full-Stack React Development with Nhost
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/react/1-introduction">
|
||||
**Current** - Set up your Nhost project
|
||||
</Card>
|
||||
|
||||
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/react/2-protected-routes">
|
||||
Route protection basics
|
||||
</Card>
|
||||
|
||||
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/react/3-user-authentication">
|
||||
Complete auth flow
|
||||
</Card>
|
||||
|
||||
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/react/4-graphql-operations">
|
||||
CRUD operations with GraphQL
|
||||
</Card>
|
||||
|
||||
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/react/5-file-uploads">
|
||||
File upload and management
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## What You'll Learn
|
||||
|
||||
Throughout this series, you'll master:
|
||||
|
||||
- Setting up and configuring Nhost projects
|
||||
- Implementing secure authentication flows
|
||||
- Building protected routes with React Router
|
||||
- Performing GraphQL queries and mutations
|
||||
- Managing file uploads and storage
|
||||
- Configuring database permissions and security
|
||||
- Building responsive React interfaces
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 20+ installed on your machine
|
||||
- Basic knowledge of React and JavaScript
|
||||
- Understanding of modern web development concepts
|
||||
|
||||
Creating an Nhost project is the first step to building your application with Nhost. Let's get started by setting up your backend infrastructure.
|
||||
|
||||
## Step-by-Step Guide
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
|
||||
### Sign Up or Log in
|
||||
|
||||
If you don't have an Nhost account, sign up at [Nhost](https://app.nhost.io/). If you already have an account, log in.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Create a New Project
|
||||
|
||||
Click on the "Create Project" button on your dashboard or follow the onboarding prompts if you're a new user.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Take note of your project subdomain and region
|
||||
|
||||
Take note of your project subdomain and region. You will need this information to connect your application to the Nhost backend in upcoming tutorials.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
## What's Next?
|
||||
|
||||
With your Nhost project created, you now have access to:
|
||||
|
||||
- [**PostgreSQL Database**](/products/database/overview) - For storing your application data
|
||||
- [**Authentication Service**](/products/auth/overview) - For managing users and sessions
|
||||
- [**GraphQL API**](/products/graphql/overview) - For querying and mutating data
|
||||
- [**File Storage**](/products/storage/overview) - For uploading and managing files
|
||||
- [**Functions**](/products/functions/overview) - For running serverless functions
|
||||
|
||||
In the [next tutorial](/getting-started/tutorials/react/2-protected-routes), you'll start building your React application and learn how to protect routes based on user authentication status.
|
||||
|
||||
<Tip>
|
||||
Keep your project subdomain and region handy - you'll need them throughout the series to connect your React application to the Nhost backend.
|
||||
</Tip>
|
||||
1435
docs/getting-started/tutorials/react/2-protected-routes.mdx
Normal file
647
docs/getting-started/tutorials/react/3-user-authentication.mdx
Normal file
@@ -0,0 +1,647 @@
|
||||
---
|
||||
title: User Authentication in React
|
||||
description: Learn how to implement user authentication in a React application using Nhost
|
||||
sidebarTitle: "User Authentication"
|
||||
icon: user
|
||||
---
|
||||
|
||||
This tutorial part builds upon the [Protected Routes part](/getting-started/tutorials/react/2-protected-routes) by adding complete email/password authentication with email verification functionality. You'll implement sign up, sign in, email verification, and sign out features to create a full authentication flow.
|
||||
|
||||
<Info>
|
||||
This is **Part 3** in the Full-Stack React Development with Nhost series. This part creates a production-ready authentication system with secure email verification and proper error handling.
|
||||
</Info>
|
||||
|
||||
## Full-Stack React Development with Nhost
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/react/1-introduction">
|
||||
Set up your Nhost project
|
||||
</Card>
|
||||
|
||||
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/react/2-protected-routes">
|
||||
Route protection basics
|
||||
</Card>
|
||||
|
||||
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/react/3-user-authentication">
|
||||
**Current** - Complete auth flow
|
||||
</Card>
|
||||
|
||||
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/react/4-graphql-operations">
|
||||
CRUD operations with GraphQL
|
||||
</Card>
|
||||
|
||||
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/react/5-file-uploads">
|
||||
File upload and management
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Complete the [Protected Routes part](/getting-started/tutorials/react/2-protected-routes) first
|
||||
- The project from the previous part set up and running
|
||||
|
||||
## Step-by-Step Guide
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
|
||||
### Create the Sign In Page
|
||||
|
||||
Build a comprehensive sign-in form with proper error handling and loading states. This page handles user authentication and includes special logic for post-verification sign-in.
|
||||
|
||||
```tsx src/pages/SignIn.tsx lines
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../lib/nhost/AuthProvider";
|
||||
|
||||
export default function SignIn() {
|
||||
const { nhost, isAuthenticated } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const emailId = useId();
|
||||
const passwordId = useId();
|
||||
|
||||
// Use useEffect for navigation after authentication is confirmed
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
navigate("/profile");
|
||||
}
|
||||
}, [isAuthenticated, navigate]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Use the signIn function from auth context
|
||||
const response = await nhost.auth.signInEmailPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
// If we have a session, sign in was successful
|
||||
if (response.body?.session) {
|
||||
navigate("/profile");
|
||||
} else {
|
||||
setError("Failed to sign in. Please check your credentials.");
|
||||
}
|
||||
} catch (err) {
|
||||
const message = (err as Error).message || "Unknown error";
|
||||
setError(`An error occurred during sign in: ${message}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Sign In</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
<div className="auth-form-field">
|
||||
<label htmlFor={emailId}>Email</label>
|
||||
<input
|
||||
id={emailId}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="auth-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="auth-form-field">
|
||||
<label htmlFor={passwordId}>Password</label>
|
||||
<input
|
||||
id={passwordId}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="auth-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="auth-error">{error}</div>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className={`auth-button secondary`}
|
||||
>
|
||||
{isLoading ? "Signing In..." : "Sign In"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="auth-links">
|
||||
<p>
|
||||
Don't have an account? <Link to="/signup">Sign Up</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Create the Sign Up Page
|
||||
|
||||
Implement user registration with email verification flow. This page collects user information, creates accounts, and guides users through the email verification process.
|
||||
|
||||
```tsx src/pages/SignUp.tsx lines
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../lib/nhost/AuthProvider";
|
||||
|
||||
export default function SignUp() {
|
||||
const { nhost, isAuthenticated } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const displayNameId = useId();
|
||||
|
||||
const emailId = useId();
|
||||
const passwordId = useId();
|
||||
|
||||
// Redirect authenticated users to profile
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
navigate("/profile");
|
||||
}
|
||||
}, [isAuthenticated, navigate]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
try {
|
||||
const response = await nhost.auth.signUpEmailPassword({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
displayName,
|
||||
// Set the redirect URL for email verification
|
||||
redirectTo: `${window.location.origin}/verify`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.body?.session) {
|
||||
// Successfully signed up and automatically signed in
|
||||
navigate("/profile");
|
||||
} else {
|
||||
// Verification email sent
|
||||
setSuccess(true);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = (err as Error).message || "Unknown error";
|
||||
setError(`An error occurred during sign up: ${message}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div>
|
||||
<h1>Check Your Email</h1>
|
||||
<div className="success-message">
|
||||
<p>
|
||||
We've sent a verification link to <strong>{email}</strong>
|
||||
</p>
|
||||
<p>
|
||||
Please check your email and click the verification link to activate
|
||||
your account.
|
||||
</p>
|
||||
</div>
|
||||
<p>
|
||||
<Link to="/signin">Back to Sign In</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Sign Up</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
<div className="auth-form-field">
|
||||
<label htmlFor={displayNameId}>Display Name</label>
|
||||
<input
|
||||
id={displayNameId}
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
required
|
||||
className="auth-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="auth-form-field">
|
||||
<label htmlFor={emailId}>Email</label>
|
||||
<input
|
||||
id={emailId}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="auth-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="auth-form-field">
|
||||
<label htmlFor={passwordId}>Password</label>
|
||||
<input
|
||||
id={passwordId}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="auth-input"
|
||||
/>
|
||||
<small className="help-text">Minimum 8 characters</small>
|
||||
</div>
|
||||
|
||||
{error && <div className="auth-error">{error}</div>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className={`auth-button primary`}
|
||||
>
|
||||
{isLoading ? "Creating Account..." : "Sign Up"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="auth-links">
|
||||
<p>
|
||||
Already have an account? <Link to="/signin">Sign In</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Create the Email Verification Page
|
||||
|
||||
Build a dedicated verification page that processes email verification tokens. This page handles the verification flow when users click the email verification link.
|
||||
|
||||
```tsx src/pages/Verify.tsx lines
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../lib/nhost/AuthProvider";
|
||||
|
||||
export default function Verify() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [status, setStatus] = useState<"verifying" | "success" | "error">(
|
||||
"verifying",
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [urlParams, setUrlParams] = useState<Record<string, string>>({});
|
||||
|
||||
const { nhost } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
// Extract the refresh token from the URL
|
||||
const params = new URLSearchParams(location.search);
|
||||
const refreshToken = params.get("refreshToken");
|
||||
|
||||
if (!refreshToken) {
|
||||
// Collect all URL parameters to display for debugging
|
||||
const allParams: Record<string, string> = {};
|
||||
params.forEach((value, key) => {
|
||||
allParams[key] = value;
|
||||
});
|
||||
setUrlParams(allParams);
|
||||
|
||||
setStatus("error");
|
||||
setError("No refresh token found in URL");
|
||||
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> = {};
|
||||
params.forEach((value, key) => {
|
||||
allParams[key] = value;
|
||||
});
|
||||
setUrlParams(allParams);
|
||||
|
||||
setStatus("error");
|
||||
setError("No refresh token found in URL");
|
||||
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) navigate("/profile");
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
const message = (err as Error).message || "Unknown error";
|
||||
if (!isMounted) return;
|
||||
|
||||
setStatus("error");
|
||||
setError(`An error occurred during verification: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
processToken();
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [location.search, navigate, nhost.auth]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Email Verification</h1>
|
||||
|
||||
<div className="page-center">
|
||||
{status === "verifying" && (
|
||||
<div>
|
||||
<p className="margin-bottom">Verifying your email...</p>
|
||||
<div className="spinner-verify" />
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "success" && (
|
||||
<div>
|
||||
<p className="verification-status">✓ Successfully verified!</p>
|
||||
<p>You'll be redirected to your profile page shortly...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<div>
|
||||
<p className="verification-status error">Verification failed</p>
|
||||
<p className="margin-bottom">{error}</p>
|
||||
|
||||
{Object.keys(urlParams).length > 0 && (
|
||||
<div className="debug-panel">
|
||||
<p className="debug-title">URL Parameters:</p>
|
||||
{Object.entries(urlParams).map(([key, value]) => (
|
||||
<div key={key} className="debug-item">
|
||||
<span className="debug-key">{key}:</span>{" "}
|
||||
<span className="debug-value">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/signin")}
|
||||
className="auth-button secondary"
|
||||
>
|
||||
Back to Sign In
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
<Warning>
|
||||
**Important Configuration Required:** Before testing email verification, you must configure your Nhost project's authentication settings:
|
||||
|
||||
1. Go to your Nhost project dashboard
|
||||
2. Navigate to **Settings → Authentication**
|
||||
3. Add your local development URL (e.g., `http://localhost:5173`) to the **Allowed Redirect URLs** field
|
||||
4. Ensure your production domain is also added when deploying
|
||||
|
||||
Without this configuration, you'll receive a `redirectTo not allowed` error when users attempt to sign up or verify their email addresses.
|
||||
</Warning>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Update the App Component to Include New Routes
|
||||
|
||||
Configure your application's routing structure to include the new authentication pages. This integrates all the authentication flows into your app's navigation.
|
||||
|
||||
```tsx src/App.tsx lines highlight={14-16,35-37}
|
||||
import {
|
||||
createBrowserRouter,
|
||||
createRoutesFromElements,
|
||||
Navigate,
|
||||
Outlet,
|
||||
Route,
|
||||
RouterProvider,
|
||||
} from "react-router-dom";
|
||||
import Navigation from "./components/Navigation";
|
||||
import ProtectedRoute from "./components/ProtectedRoute";
|
||||
import { AuthProvider } from "./lib/nhost/AuthProvider";
|
||||
import Home from "./pages/Home";
|
||||
import Profile from "./pages/Profile";
|
||||
import SignIn from "./pages/SignIn";
|
||||
import SignUp from "./pages/SignUp";
|
||||
import Verify from "./pages/Verify";
|
||||
|
||||
// Root layout component to wrap all routes
|
||||
const RootLayout = () => {
|
||||
return (
|
||||
<>
|
||||
<Navigation />
|
||||
<div className="app-content">
|
||||
<Outlet />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Create router with routes
|
||||
const router = createBrowserRouter(
|
||||
createRoutesFromElements(
|
||||
<Route element={<RootLayout />}>
|
||||
<Route index element={<Home />} />
|
||||
<Route path="signin" element={<SignIn />} />
|
||||
<Route path="signup" element={<SignUp />} />
|
||||
<Route path="verify" element={<Verify />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="profile" element={<Profile />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Route>,
|
||||
),
|
||||
);
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Add Navigation Links and Sign Out Functionality
|
||||
|
||||
Update the navigation component to include links to the sign-in and sign-up pages, and implement the sign-out.
|
||||
|
||||
```tsx src/components/Navigation.tsx lines highlight={1,5-19,38-43,47-52}
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../lib/nhost/AuthProvider";
|
||||
|
||||
export default function Navigation() {
|
||||
const { isAuthenticated, session, nhost } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
if (session) {
|
||||
await nhost.auth.signOut({
|
||||
refreshToken: session.refreshToken,
|
||||
});
|
||||
}
|
||||
navigate("/");
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error("Error signing out:", message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="navigation">
|
||||
<div className="nav-container">
|
||||
<Link to="/" className="nav-logo">
|
||||
Nhost React Demo
|
||||
</Link>
|
||||
|
||||
<div className="nav-links">
|
||||
<Link to="/" className="nav-link">
|
||||
Home
|
||||
</Link>
|
||||
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<Link to="/profile" className="nav-link">
|
||||
Profile
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSignOut}
|
||||
className="nav-link nav-button"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/signin" className="nav-link">
|
||||
Sign In
|
||||
</Link>
|
||||
<Link to="/signup" className="nav-link">
|
||||
Sign Up
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Run and Test the Application
|
||||
|
||||
Start your development server and test the complete authentication flow to ensure everything works properly.
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
|
||||
Things to try out:
|
||||
|
||||
1. Try signing up with a new email address. Check your email for the verification link and click it. See how you are sent to the verification page and then redirected to your profile.
|
||||
2. Try signing out and then signing back in with the same credentials.
|
||||
3. Notice how navigation links change based on authentication state showing "Sign In" and "Sign Up" when logged out, and "Profile" and "Sign Out" when logged in.
|
||||
4. Check how the homepage also reflects the authentication state with appropriate messages.
|
||||
5. Open multiple tabs and test signing out from one tab to see how other tabs respond. Now sign back in and see the changes propagate across tabs.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Key Features Demonstrated
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Complete Registration Flow" icon="user-plus">
|
||||
Full email/password registration with proper form validation and user feedback.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Email Verification" icon="envelope-circle-check">
|
||||
Custom `/verify` endpoint that securely processes email verification tokens.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Error Handling" icon="triangle-exclamation">
|
||||
Comprehensive error handling for unverified emails, failed authentication, and network issues.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Visual Feedback" icon="eye">
|
||||
Loading states, success messages, and clear error displays throughout the authentication flow.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Session Management" icon="clock">
|
||||
Complete sign out functionality and proper session state management across the application.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
856
docs/getting-started/tutorials/react/4-graphql-operations.mdx
Normal file
@@ -0,0 +1,856 @@
|
||||
---
|
||||
title: GraphQL Operations in React
|
||||
description: Learn how to perform GraphQL operations and manage database permissions while building a complete todos application with Nhost and React
|
||||
sidebarTitle: "GraphQL Operations"
|
||||
icon: code
|
||||
---
|
||||
|
||||
This part builds upon the previous parts by demonstrating how to perform GraphQL operations with proper database permissions. You'll learn how to design database tables, configure user permissions, and implement complete CRUD operations through GraphQL queries and mutations in a real todos application.
|
||||
|
||||
<Info>
|
||||
This is **Part 4** in the Full-Stack React Development with Nhost series. This part focuses on GraphQL operations, database management, and permission-based data access control in a production application.
|
||||
</Info>
|
||||
|
||||
## Full-Stack React Development with Nhost
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/react/1-introduction">
|
||||
Set up your Nhost project
|
||||
</Card>
|
||||
|
||||
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/react/2-protected-routes">
|
||||
Route protection basics
|
||||
</Card>
|
||||
|
||||
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/react/3-user-authentication">
|
||||
Complete auth flow
|
||||
</Card>
|
||||
|
||||
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/react/4-graphql-operations">
|
||||
**Current** - CRUD operations with GraphQL
|
||||
</Card>
|
||||
|
||||
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/react/5-file-uploads">
|
||||
File upload and management
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Complete the [User Authentication part](/getting-started/tutorials/react/3-user-authentication) first
|
||||
- The project from the previous part set up and running
|
||||
|
||||
## What You'll Build
|
||||
|
||||
By the end of this part, you'll have:
|
||||
- **GraphQL queries and mutations** for complete CRUD operations
|
||||
- **Database schema** with proper relationships and constraints
|
||||
- **User permissions** for secure data access control
|
||||
- **React components** that interact with GraphQL endpoint
|
||||
|
||||
## Step-by-Step Guide
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
|
||||
### Create the To-Dos Table
|
||||
|
||||
First, we'll perform the database changes to set up the todos table with proper schema and relationships to users.
|
||||
|
||||
In your Nhost project dashboard:
|
||||
1. Navigate to **Database**
|
||||
2. Click on the SQL Editor
|
||||
|
||||
Enter the following SQL:
|
||||
|
||||
<Tabs>
|
||||
|
||||
<Tab title="SQL">
|
||||
|
||||
```sql
|
||||
CREATE TABLE public.todos (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
created_at timestamptz DEFAULT now() NOT NULL,
|
||||
updated_at timestamptz DEFAULT now() NOT NULL,
|
||||
title text NOT NULL,
|
||||
details text,
|
||||
completed bool DEFAULT false NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (user_id) REFERENCES auth.users (id) ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
|
||||
CREATE TRIGGER update_todos_updated_at
|
||||
BEFORE UPDATE ON public.todos
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
```
|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab title="UI">
|
||||

|
||||
</Tab>
|
||||
|
||||
|
||||
</Tabs>
|
||||
|
||||
<Warning>
|
||||
Please make sure to enable **Track this** so that the new table todos is available through the auto-generated APIs
|
||||
</Warning>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Set Up Permissions
|
||||
|
||||
It’s now time to set permission rules for the table you just created. With the table `todos` selected, click on **…**, followed by **Edit Permissions**.
|
||||
You will set permissions for the **user** role and actions **insert**, **select**, **update**, and **delete**.
|
||||
|
||||
<Tabs>
|
||||
|
||||
<Tab title="Insert">
|
||||
|
||||
When inserting permissions we are only allowing users to set the `title`, `details`, and `completed` columns as the rest of the columns are set automatically by the backend. The `user_id` column is configured as a preset to the currently authenticated user's ID using the `X-Hasura-User-Id` session variable. This ensures that each todo is associated with the user who created it.
|
||||
|
||||
|
||||

|
||||
</Tab>
|
||||
|
||||
<Tab title="Select">
|
||||
|
||||
For selecting (reading) todos, we are allowing to read all columns but only for rows where the `user_id` matches the authenticated user's ID. This ensures that users can only see their own todos.
|
||||
|
||||

|
||||
</Tab>
|
||||
|
||||
<Tab title="Update">
|
||||
|
||||
When updating todos, we are allowing users to modify the `title`, `details`, and `completed` columns but only for rows where the `user_id` matches their own ID. This prevents users from modifying todos that do not belong to them.
|
||||
|
||||

|
||||
</Tab>
|
||||
|
||||
<Tab title="Delete">
|
||||
|
||||
For deleting todos, we are allowing users to delete rows only where the `user_id` matches their own ID. This ensures that users cannot delete todos that belong to other users.
|
||||
|
||||

|
||||
</Tab>
|
||||
|
||||
</Tabs>
|
||||
|
||||
</Step>
|
||||
|
||||
|
||||
<Step>
|
||||
|
||||
### Create the Todos Page Component
|
||||
|
||||
Now let's implement the React component that uses the database we just configured.
|
||||
|
||||
```tsx src/pages/Todos.tsx lines
|
||||
import type { JSX } from "react";
|
||||
import { useCallback, useEffect, useId, useState } from "react";
|
||||
import { useAuth } from "../lib/nhost/AuthProvider";
|
||||
|
||||
// The interfaces below define the structure of our data
|
||||
// They are not strictly necessary but help with type safety
|
||||
|
||||
// Represents a single todo item
|
||||
interface Todo {
|
||||
id: string;
|
||||
title: string;
|
||||
details: string | null;
|
||||
completed: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
// This matches the GraphQL response structure for fetching todos
|
||||
// Can be used as a generic type on the request method
|
||||
interface GetTodos {
|
||||
todos: Todo[];
|
||||
}
|
||||
|
||||
// This matches the GraphQL response structure for inserting a todo
|
||||
// Can be used as a generic type on the request method
|
||||
interface InsertTodo {
|
||||
insert_todos_one: Todo | null;
|
||||
}
|
||||
|
||||
// This matches the GraphQL response structure for updating a todo
|
||||
// Can be used as a generic type on the request method
|
||||
interface UpdateTodo {
|
||||
update_todos_by_pk: Todo | null;
|
||||
}
|
||||
|
||||
export default function Todos(): JSX.Element {
|
||||
const { nhost, session } = useAuth();
|
||||
const [todos, setTodos] = useState<Todo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [newTodoTitle, setNewTodoTitle] = useState("");
|
||||
const [newTodoDetails, setNewTodoDetails] = useState("");
|
||||
const [editingTodo, setEditingTodo] = useState<Todo | null>(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [expandedTodos, setExpandedTodos] = useState<Set<string>>(new Set());
|
||||
|
||||
const titleId = useId();
|
||||
const detailsId = useId();
|
||||
|
||||
const fetchTodos = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// Make GraphQL request to fetch todos using Nhost client
|
||||
// The query automatically filters by user_id due to Hasura permissions
|
||||
const response = await nhost.graphql.request<GetTodos>({
|
||||
query: `
|
||||
query GetTodos {
|
||||
todos(order_by: { created_at: desc }) {
|
||||
id
|
||||
title
|
||||
details
|
||||
completed
|
||||
created_at
|
||||
updated_at
|
||||
user_id
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
// Check for GraphQL errors in the response body
|
||||
if (response.body.errors) {
|
||||
throw new Error(
|
||||
response.body.errors[0]?.message || "Failed to fetch todos",
|
||||
);
|
||||
}
|
||||
|
||||
// Extract todos from the GraphQL response data
|
||||
setTodos(response.body?.data?.todos || []);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch todos");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [nhost.graphql]);
|
||||
|
||||
const addTodo = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newTodoTitle.trim()) return;
|
||||
|
||||
try {
|
||||
// Execute GraphQL mutation to insert a new todo
|
||||
// user_id is automatically set by Hasura based on JWT token
|
||||
const response = await nhost.graphql.request<InsertTodo>({
|
||||
query: `
|
||||
mutation InsertTodo($title: String!, $details: String) {
|
||||
insert_todos_one(object: { title: $title, details: $details }) {
|
||||
id
|
||||
title
|
||||
details
|
||||
completed
|
||||
created_at
|
||||
updated_at
|
||||
user_id
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
title: newTodoTitle.trim(),
|
||||
details: newTodoDetails.trim() || null,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.body.errors) {
|
||||
throw new Error(
|
||||
response.body.errors[0]?.message || "Failed to add todo",
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.body?.data?.insert_todos_one) {
|
||||
throw new Error("Failed to add todo");
|
||||
}
|
||||
setTodos([response.body?.data?.insert_todos_one, ...todos]);
|
||||
setNewTodoTitle("");
|
||||
setNewTodoDetails("");
|
||||
setShowAddForm(false);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to add todo");
|
||||
}
|
||||
};
|
||||
|
||||
const updateTodo = async (
|
||||
id: string,
|
||||
updates: Partial<Pick<Todo, "title" | "details" | "completed">>,
|
||||
) => {
|
||||
try {
|
||||
// Execute GraphQL mutation to update an existing todo by primary key
|
||||
// Hasura permissions ensure users can only update their own todos
|
||||
const response = await nhost.graphql.request<UpdateTodo>({
|
||||
query: `
|
||||
mutation UpdateTodo($id: uuid!, $updates: todos_set_input!) {
|
||||
update_todos_by_pk(pk_columns: { id: $id }, _set: $updates) {
|
||||
id
|
||||
title
|
||||
details
|
||||
completed
|
||||
created_at
|
||||
updated_at
|
||||
user_id
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
id,
|
||||
updates,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.body.errors) {
|
||||
throw new Error(
|
||||
response.body.errors[0]?.message || "Failed to update todo",
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.body?.data?.update_todos_by_pk) {
|
||||
throw new Error("Failed to update todo");
|
||||
}
|
||||
|
||||
const updatedTodo = response.body?.data?.update_todos_by_pk;
|
||||
if (updatedTodo) {
|
||||
setTodos(todos.map((todo) => (todo.id === id ? updatedTodo : todo)));
|
||||
}
|
||||
setEditingTodo(null);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to update todo");
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTodo = async (id: string) => {
|
||||
if (!confirm("Are you sure you want to delete this todo?")) return;
|
||||
|
||||
try {
|
||||
// Execute GraphQL mutation to delete a todo by primary key
|
||||
// Hasura permissions ensure users can only delete their own todos
|
||||
const response = await nhost.graphql.request({
|
||||
query: `
|
||||
mutation DeleteTodo($id: uuid!) {
|
||||
delete_todos_by_pk(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.body.errors) {
|
||||
throw new Error(
|
||||
response.body.errors[0]?.message || "Failed to delete todo",
|
||||
);
|
||||
}
|
||||
|
||||
setTodos(todos.filter((todo) => todo.id !== id));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to delete todo");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleComplete = async (todo: Todo) => {
|
||||
await updateTodo(todo.id, { completed: !todo.completed });
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!editingTodo) return;
|
||||
await updateTodo(editingTodo.id, {
|
||||
title: editingTodo.title,
|
||||
details: editingTodo.details,
|
||||
});
|
||||
};
|
||||
|
||||
const toggleTodoExpansion = (todoId: string) => {
|
||||
const newExpanded = new Set(expandedTodos);
|
||||
if (newExpanded.has(todoId)) {
|
||||
newExpanded.delete(todoId);
|
||||
} else {
|
||||
newExpanded.add(todoId);
|
||||
}
|
||||
setExpandedTodos(newExpanded);
|
||||
};
|
||||
|
||||
// Fetch todos when user session is available
|
||||
// The session contains the JWT token needed for GraphQL authentication
|
||||
useEffect(() => {
|
||||
if (session) {
|
||||
fetchTodos();
|
||||
}
|
||||
}, [session, fetchTodos]);
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="auth-message">
|
||||
<p>Please sign in to view your todos.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<header className="page-header">
|
||||
<h1 className="page-title">
|
||||
My Todos
|
||||
{!showAddForm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="add-todo-btn"
|
||||
title="Add a new todo"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAddForm && (
|
||||
<div className="todo-form-card">
|
||||
<form onSubmit={addTodo} className="todo-form">
|
||||
<h2 className="form-title">Add New Todo</h2>
|
||||
<div className="form-fields">
|
||||
<div className="field-group">
|
||||
<label htmlFor={titleId}>Title *</label>
|
||||
<input
|
||||
id={titleId}
|
||||
type="text"
|
||||
value={newTodoTitle}
|
||||
onChange={(e) => setNewTodoTitle(e.target.value)}
|
||||
placeholder="What needs to be done?"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="field-group">
|
||||
<label htmlFor={detailsId}>Details</label>
|
||||
<textarea
|
||||
id={detailsId}
|
||||
value={newTodoDetails}
|
||||
onChange={(e) => setNewTodoDetails(e.target.value)}
|
||||
placeholder="Add some details (optional)..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Add Todo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowAddForm(false);
|
||||
setNewTodoTitle("");
|
||||
setNewTodoDetails("");
|
||||
}}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showAddForm &&
|
||||
(loading ? (
|
||||
<div className="loading-container">
|
||||
<div className="loading-content">
|
||||
<div className="spinner"></div>
|
||||
<span className="loading-text">Loading todos...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="todos-list">
|
||||
{todos.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<svg
|
||||
className="empty-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="empty-title">No todos yet</h3>
|
||||
<p className="empty-description">
|
||||
Create your first todo to get started!
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
todos.map((todo) => (
|
||||
<div
|
||||
key={todo.id}
|
||||
className={`todo-card ${todo.completed ? "completed" : ""}`}
|
||||
>
|
||||
{editingTodo?.id === todo.id ? (
|
||||
<div className="todo-edit">
|
||||
<div className="edit-fields">
|
||||
<div className="field-group">
|
||||
<label htmlFor={`${titleId}-edit`}>Title</label>
|
||||
<input
|
||||
id={`${titleId}-edit`}
|
||||
type="text"
|
||||
value={editingTodo.title}
|
||||
onChange={(e) =>
|
||||
setEditingTodo({
|
||||
...editingTodo,
|
||||
title: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="field-group">
|
||||
<label htmlFor={`${detailsId}-edit`}>Details</label>
|
||||
<textarea
|
||||
id={`${detailsId}-edit`}
|
||||
value={editingTodo.details || ""}
|
||||
onChange={(e) =>
|
||||
setEditingTodo({
|
||||
...editingTodo,
|
||||
details: e.target.value,
|
||||
})
|
||||
}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="edit-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveEdit}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
✓ Save Changes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingTodo(null)}
|
||||
className="btn btn-cancel"
|
||||
>
|
||||
✕ Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="todo-content">
|
||||
<div className="todo-header">
|
||||
<button
|
||||
type="button"
|
||||
className={`todo-title-btn ${todo.completed ? "completed" : ""}`}
|
||||
onClick={() => toggleTodoExpansion(todo.id)}
|
||||
>
|
||||
{todo.title}
|
||||
</button>
|
||||
<div className="todo-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleComplete(todo)}
|
||||
className="action-btn action-btn-complete"
|
||||
title={
|
||||
todo.completed
|
||||
? "Mark as incomplete"
|
||||
: "Mark as complete"
|
||||
}
|
||||
>
|
||||
{todo.completed ? "↶" : "✓"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingTodo(todo)}
|
||||
className="action-btn action-btn-edit"
|
||||
title="Edit todo"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteTodo(todo.id)}
|
||||
className="action-btn action-btn-delete"
|
||||
title="Delete todo"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedTodos.has(todo.id) && (
|
||||
<div className="todo-details">
|
||||
{todo.details && (
|
||||
<div
|
||||
className={`todo-description ${todo.completed ? "completed" : ""}`}
|
||||
>
|
||||
<p>{todo.details}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="todo-meta">
|
||||
<div className="meta-dates">
|
||||
<span className="meta-item">
|
||||
Created:{" "}
|
||||
{new Date(todo.created_at).toLocaleString()}
|
||||
</span>
|
||||
<span className="meta-item">
|
||||
Updated:{" "}
|
||||
{new Date(todo.updated_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
{todo.completed && (
|
||||
<div className="completion-badge">
|
||||
<svg
|
||||
className="completion-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Completed</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Update App Routes
|
||||
|
||||
Add the todos page to your application routing.
|
||||
|
||||
```tsx src/App.tsx lines highlight={17,41}
|
||||
import {
|
||||
createBrowserRouter,
|
||||
createRoutesFromElements,
|
||||
Navigate,
|
||||
Outlet,
|
||||
Route,
|
||||
RouterProvider,
|
||||
} from "react-router-dom";
|
||||
import Navigation from "./components/Navigation";
|
||||
import ProtectedRoute from "./components/ProtectedRoute";
|
||||
import { AuthProvider } from "./lib/nhost/AuthProvider";
|
||||
import Home from "./pages/Home";
|
||||
import Profile from "./pages/Profile";
|
||||
import SignIn from "./pages/SignIn";
|
||||
import SignUp from "./pages/SignUp";
|
||||
import Todos from "./pages/Todos";
|
||||
import Verify from "./pages/Verify";
|
||||
|
||||
// Root layout component to wrap all routes
|
||||
const RootLayout = () => {
|
||||
return (
|
||||
<>
|
||||
<Navigation />
|
||||
<div className="app-content">
|
||||
<Outlet />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Create router with routes
|
||||
const router = createBrowserRouter(
|
||||
createRoutesFromElements(
|
||||
<Route element={<RootLayout />}>
|
||||
<Route index element={<Home />} />
|
||||
<Route path="signin" element={<SignIn />} />
|
||||
<Route path="signup" element={<SignUp />} />
|
||||
<Route path="verify" element={<Verify />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="profile" element={<Profile />} />
|
||||
<Route path="todos" element={<Todos />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Route>,
|
||||
),
|
||||
);
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Update Navigation Links
|
||||
|
||||
Add a link to the todos page in the navigation bar.
|
||||
|
||||
```tsx src/components/Navigation.tsx lines highlight={35-37}
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../lib/nhost/AuthProvider";
|
||||
|
||||
export default function Navigation() {
|
||||
const { isAuthenticated, session, nhost } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
if (session) {
|
||||
await nhost.auth.signOut({
|
||||
refreshToken: session.refreshToken,
|
||||
});
|
||||
}
|
||||
navigate("/");
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error("Error signing out:", message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="navigation">
|
||||
<div className="nav-container">
|
||||
<Link to="/" className="nav-logo">
|
||||
Nhost React Demo
|
||||
</Link>
|
||||
|
||||
<div className="nav-links">
|
||||
<Link to="/" className="nav-link">
|
||||
Home
|
||||
</Link>
|
||||
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<Link to="/todos" className="nav-link">
|
||||
Todos
|
||||
</Link>
|
||||
<Link to="/profile" className="nav-link">
|
||||
Profile
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSignOut}
|
||||
className="nav-link nav-button"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/signin" className="nav-link">
|
||||
Sign In
|
||||
</Link>
|
||||
<Link to="/signup" className="nav-link">
|
||||
Sign Up
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Test Your Complete Application
|
||||
|
||||
Run your application and test all the functionality:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Things to try out:
|
||||
|
||||
1. Try signing in and out and see how the Todos page is only available when authenticated
|
||||
2. Create, view, edit, complete, and delete todos. See how the UI updates accordingly
|
||||
3. Open the application in another browser or incognito window, sign in with a different account and verify that you cannot see or modify todos from the first account
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Key Features Implemented
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Database Schema" icon="database">
|
||||
Properly designed todos table with constraints, indexes, and automatic timestamp updates for optimal performance.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="GraphQL API" icon="webhook">
|
||||
Auto-generated GraphQL API with queries and mutations for full CRUD operations on todos.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Row-Level Security" icon="shield-check">
|
||||
Comprehensive permissions ensuring users can only access their own todos through all GraphQL operations.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="CRUD Operations" icon="arrows-rotate">
|
||||
Complete Create, Read, Update, Delete functionality with proper error handling and user feedback.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Rich Interface" icon="sparkles">
|
||||
Expandable todo items, inline editing, completion status, and detailed timestamps.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
715
docs/getting-started/tutorials/react/5-file-uploads.mdx
Normal file
@@ -0,0 +1,715 @@
|
||||
---
|
||||
title: File Uploads in React
|
||||
description: Learn how to implement file upload functionality with storage buckets and permissions while building a complete file management system with Nhost and React
|
||||
sidebarTitle: "File Uploads"
|
||||
icon: upload
|
||||
---
|
||||
|
||||
This part builds upon the previous GraphQL operations part by demonstrating how to implement file upload functionality with proper storage permissions. You'll learn how to create storage buckets, configure upload permissions, and implement complete file management operations in a React application.
|
||||
|
||||
<Info>
|
||||
This is **Part 5** in the Full-Stack React Development with Nhost series. This part focuses on file storage, upload operations, and permission-based file access control in a production application.
|
||||
</Info>
|
||||
|
||||
## Full-Stack React Development with Nhost
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/react/1-introduction">
|
||||
Set up your Nhost project
|
||||
</Card>
|
||||
|
||||
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/react/2-protected-routes">
|
||||
Route protection basics
|
||||
</Card>
|
||||
|
||||
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/react/3-user-authentication">
|
||||
Complete auth flow
|
||||
</Card>
|
||||
|
||||
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/react/4-graphql-operations">
|
||||
CRUD operations with GraphQL
|
||||
</Card>
|
||||
|
||||
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/react/5-file-uploads">
|
||||
**Current** - File upload and management
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Complete the [GraphQL Operations part](/getting-started/tutorials/react/4-graphql-operations) first
|
||||
- The project from the previous part set up and running
|
||||
|
||||
## What You'll Build
|
||||
|
||||
By the end of this part, you'll have:
|
||||
- A **personal bucket** so users can upload their own private files
|
||||
- **File upload functionality**
|
||||
- **File management interface** for viewing and deleting files
|
||||
- **Security permissions** ensuring users can only access their own files
|
||||
|
||||
## Step-by-Step Guide
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
|
||||
### Create a Personal Storage Bucket
|
||||
|
||||
First, we'll create a storage bucket where users can upload their personal files.
|
||||
|
||||
In your Nhost project dashboard:
|
||||
1. Navigate to **Database**
|
||||
2. Change to **schema.storage**, then buckets
|
||||
3. Now click on `+ Insert` on the top right corner.
|
||||
4. As id set `personal`, leave the rest of the fields blank and click on Insert at the bottom
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Configure Storage Permissions
|
||||
|
||||
Now we need to set up permissions for the storage bucket to ensure the `user` role can only upload, view, and delete their own files.
|
||||
|
||||
<Tabs>
|
||||
|
||||
<Tab title="Upload">
|
||||
|
||||
To upload files we need to grant permissions to insert on the table `storage.files`. Because we want to allow uploading files only to the `personal` bucket we will be using the `bucket_id eq personal` as a custom check. In addition, we are configuring a preset `uploaded_by_user_id = X-Hasura-User-id`, this will automatically extract the user_id from the session and set the column accordingly. Then we can use this in other permissions to allow downloading files and deleting them.
|
||||
|
||||

|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab title="Download">
|
||||
|
||||
To download files users need to be able to query those files. To make sure users can only download files they uploaded we will be leveraging the column `uploaded_by_user_id` column from before and the `bucket_id``.
|
||||
|
||||

|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab title="Delete">
|
||||
|
||||
Similarly to downloading files, to delete files users need to be able to delete rows from the `storage.files` table. Again we will use the `uploaded_by_user_id` column and the `bucket_id` to make sure users can only delete their own files.
|
||||
|
||||

|
||||
|
||||
</Tab>
|
||||
|
||||
</Tabs>
|
||||
|
||||
<Info>
|
||||
You can read more about storage permissions [here](/products/storage/overview#permissions)
|
||||
</Info>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Create the File Upload Component
|
||||
|
||||
Now let's implement the React component for file upload functionality.
|
||||
|
||||
```tsx src/pages/Files.tsx lines
|
||||
import type { FileMetadata } from "@nhost/nhost-js/storage";
|
||||
import { type JSX, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useAuth } from "../lib/nhost/AuthProvider";
|
||||
|
||||
interface DeleteStatus {
|
||||
message: string;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
interface GraphqlGetFilesResponse {
|
||||
files: FileMetadata[];
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
|
||||
const sizes: string[] = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
const i: number = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
|
||||
return `${parseFloat((bytes / 1024 ** i).toFixed(2))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export default function Files(): JSX.Element {
|
||||
const { isAuthenticated, nhost } = useAuth();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<File | 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 (): Promise<void> => {
|
||||
setIsFetching(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Use GraphQL to fetch files from the storage system
|
||||
// Files are automatically filtered by user permissions
|
||||
const response = await nhost.graphql.request<GraphqlGetFilesResponse>({
|
||||
query: `query GetFiles {
|
||||
files {
|
||||
id
|
||||
name
|
||||
size
|
||||
mimeType
|
||||
bucketId
|
||||
uploadedByUserId
|
||||
}
|
||||
}`,
|
||||
});
|
||||
|
||||
if (response.body.errors) {
|
||||
throw new Error(
|
||||
response.body.errors[0]?.message || "Failed to fetch files",
|
||||
);
|
||||
}
|
||||
|
||||
setFiles(response.body.data?.files || []);
|
||||
} catch (err) {
|
||||
console.error("Error fetching files:", err);
|
||||
setError("Failed to load files. Please try refreshing the page.");
|
||||
} finally {
|
||||
setIsFetching(false);
|
||||
}
|
||||
}, [nhost.graphql]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
fetchFiles();
|
||||
}
|
||||
}, [isAuthenticated, fetchFiles]);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
setSelectedFile(file);
|
||||
setError(null);
|
||||
setUploadResult(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async (): Promise<void> => {
|
||||
if (!selectedFile) {
|
||||
setError("Please select a file to upload");
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Upload file to the personal bucket
|
||||
// The uploadedByUserId is automatically set by the storage permissions
|
||||
const response = await nhost.storage.uploadFiles({
|
||||
"bucket-id": "personal",
|
||||
"file[]": [selectedFile],
|
||||
});
|
||||
|
||||
const uploadedFile = response.body.processedFiles?.[0];
|
||||
if (uploadedFile === undefined) {
|
||||
throw new Error("Failed to upload file");
|
||||
}
|
||||
setUploadResult(uploadedFile);
|
||||
|
||||
// Clear the form
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
|
||||
// Update the files list
|
||||
setFiles((prevFiles) => [uploadedFile, ...prevFiles]);
|
||||
|
||||
await fetchFiles();
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
setUploadResult(null);
|
||||
}, 3000);
|
||||
} catch (err: unknown) {
|
||||
const message = (err as Error).message || "An unknown error occurred";
|
||||
setError(`Failed to upload file: ${message}`);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewFile = async (
|
||||
fileId: string,
|
||||
fileName: string,
|
||||
mimeType: string,
|
||||
): Promise<void> => {
|
||||
setViewingFile(fileId);
|
||||
|
||||
try {
|
||||
// Get the file from storage
|
||||
const response = await nhost.storage.getFile(fileId);
|
||||
|
||||
const url = URL.createObjectURL(response.body);
|
||||
|
||||
// Handle different file types appropriately
|
||||
if (
|
||||
mimeType.startsWith("image/") ||
|
||||
mimeType === "application/pdf" ||
|
||||
mimeType.startsWith("text/") ||
|
||||
mimeType.startsWith("video/") ||
|
||||
mimeType.startsWith("audio/")
|
||||
) {
|
||||
// Open viewable files in new tab
|
||||
window.open(url, "_blank");
|
||||
} else {
|
||||
// Download other file types
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// Show download confirmation
|
||||
const newWindow = window.open("", "_blank", "width=400,height=200");
|
||||
if (newWindow) {
|
||||
newWindow.document.documentElement.innerHTML = `
|
||||
<head>
|
||||
<title>File Download</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; padding: 20px; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h3>Downloading: ${fileName}</h3>
|
||||
<p>Your download has started. You can close this window.</p>
|
||||
</body>
|
||||
`;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const message = (err as Error).message || "An unknown error occurred";
|
||||
setError(`Failed to view file: ${message}`);
|
||||
console.error("Error viewing file:", err);
|
||||
} finally {
|
||||
setViewingFile(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFile = async (fileId: string): Promise<void> => {
|
||||
if (!fileId || deleting) return;
|
||||
|
||||
setDeleting(fileId);
|
||||
setError(null);
|
||||
setDeleteStatus(null);
|
||||
|
||||
const fileToDelete = files.find((file) => file.id === fileId);
|
||||
const fileName = fileToDelete?.name || "File";
|
||||
|
||||
try {
|
||||
// Delete file from storage
|
||||
// Permissions ensure users can only delete their own files
|
||||
await nhost.storage.deleteFile(fileId);
|
||||
|
||||
setDeleteStatus({
|
||||
message: `${fileName} deleted successfully`,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
// Remove from local state
|
||||
setFiles(files.filter((file) => file.id !== fileId));
|
||||
|
||||
await fetchFiles();
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
setDeleteStatus(null);
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
const message = (err as Error).message || "An unknown error occurred";
|
||||
setDeleteStatus({
|
||||
message: `Failed to delete ${fileName}: ${message}`,
|
||||
isError: true,
|
||||
});
|
||||
console.error("Error deleting file:", err);
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<header className="page-header">
|
||||
<h1 className="page-title">File Upload</h1>
|
||||
</header>
|
||||
|
||||
<div className="form-card">
|
||||
<h2 className="form-title">Upload a File</h2>
|
||||
|
||||
<div className="field-group">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: "1px",
|
||||
height: "1px",
|
||||
padding: 0,
|
||||
margin: "-1px",
|
||||
overflow: "hidden",
|
||||
clip: "rect(0,0,0,0)",
|
||||
border: 0,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary file-upload-btn"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
role="img"
|
||||
aria-label="Upload file"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
<p>Click to select a file</p>
|
||||
{selectedFile && (
|
||||
<p className="file-upload-info">
|
||||
{selectedFile.name} ({formatFileSize(selectedFile.size)})
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
{uploadResult && (
|
||||
<div className="success-message">File uploaded successfully!</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUpload}
|
||||
disabled={!selectedFile || uploading}
|
||||
className="btn btn-primary"
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{uploading ? "Uploading..." : "Upload File"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="form-card">
|
||||
<h2 className="form-title">Your Files</h2>
|
||||
|
||||
{deleteStatus && (
|
||||
<div
|
||||
className={
|
||||
deleteStatus.isError ? "error-message" : "success-message"
|
||||
}
|
||||
>
|
||||
{deleteStatus.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isFetching ? (
|
||||
<div className="loading-container">
|
||||
<div className="loading-content">
|
||||
<div className="spinner"></div>
|
||||
<span className="loading-text">Loading files...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<svg
|
||||
className="empty-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="empty-title">No files yet</h3>
|
||||
<p className="empty-description">
|
||||
Upload your first file to get started!
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table className="file-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Size</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{files.map((file) => (
|
||||
<tr key={file.id}>
|
||||
<td className="file-name">{file.name}</td>
|
||||
<td className="file-meta">{file.mimeType}</td>
|
||||
<td className="file-meta">
|
||||
{formatFileSize(file.size || 0)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="file-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleViewFile(
|
||||
file.id || "unknown",
|
||||
file.name || "unknown",
|
||||
file.mimeType || "unknown",
|
||||
)
|
||||
}
|
||||
disabled={viewingFile === file.id}
|
||||
className="action-btn action-btn-edit"
|
||||
title="View File"
|
||||
>
|
||||
{viewingFile === file.id ? "⏳" : "👁️"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteFile(file.id || "unknown")}
|
||||
disabled={deleting === file.id}
|
||||
className="action-btn action-btn-delete"
|
||||
title="Delete File"
|
||||
>
|
||||
{deleting === file.id ? "⏳" : "🗑️"}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Update Application Routes
|
||||
|
||||
Add the uploads page to your application routing.
|
||||
|
||||
```tsx src/App.tsx lines highlight={18,43}
|
||||
import {
|
||||
createBrowserRouter,
|
||||
createRoutesFromElements,
|
||||
Navigate,
|
||||
Outlet,
|
||||
Route,
|
||||
RouterProvider,
|
||||
} from "react-router-dom";
|
||||
import Navigation from "./components/Navigation";
|
||||
import ProtectedRoute from "./components/ProtectedRoute";
|
||||
import { AuthProvider } from "./lib/nhost/AuthProvider";
|
||||
import Files from "./pages/Files";
|
||||
import Home from "./pages/Home";
|
||||
import Profile from "./pages/Profile";
|
||||
import SignIn from "./pages/SignIn";
|
||||
import SignUp from "./pages/SignUp";
|
||||
import Todos from "./pages/Todos";
|
||||
import Verify from "./pages/Verify";
|
||||
|
||||
// Root layout component to wrap all routes
|
||||
const RootLayout = () => {
|
||||
return (
|
||||
<>
|
||||
<Navigation />
|
||||
<div className="app-content">
|
||||
<Outlet />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Create router with routes
|
||||
const router = createBrowserRouter(
|
||||
createRoutesFromElements(
|
||||
<Route element={<RootLayout />}>
|
||||
<Route index element={<Home />} />
|
||||
<Route path="signin" element={<SignIn />} />
|
||||
<Route path="signup" element={<SignUp />} />
|
||||
<Route path="verify" element={<Verify />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="profile" element={<Profile />} />
|
||||
<Route path="todos" element={<Todos />} />
|
||||
<Route path="files" element={<Files />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Route>,
|
||||
),
|
||||
);
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Update Navigation Links
|
||||
|
||||
Add a link to the uploads page in the navigation bar.
|
||||
|
||||
```tsx src/components/Navigation.tsx lines highlight={38-40}
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../lib/nhost/AuthProvider";
|
||||
|
||||
export default function Navigation() {
|
||||
const { isAuthenticated, session, nhost } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
if (session) {
|
||||
await nhost.auth.signOut({
|
||||
refreshToken: session.refreshToken,
|
||||
});
|
||||
}
|
||||
navigate("/");
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error("Error signing out:", message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="navigation">
|
||||
<div className="nav-container">
|
||||
<Link to="/" className="nav-logo">
|
||||
Nhost React Demo
|
||||
</Link>
|
||||
|
||||
<div className="nav-links">
|
||||
<Link to="/" className="nav-link">
|
||||
Home
|
||||
</Link>
|
||||
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<Link to="/todos" className="nav-link">
|
||||
Todos
|
||||
</Link>
|
||||
<Link to="/files" className="nav-link">
|
||||
Files
|
||||
</Link>
|
||||
<Link to="/profile" className="nav-link">
|
||||
Profile
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSignOut}
|
||||
className="nav-link nav-button"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/signin" className="nav-link">
|
||||
Sign In
|
||||
</Link>
|
||||
<Link to="/signup" className="nav-link">
|
||||
Sign Up
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Test Your File Upload System
|
||||
|
||||
Run your application and test all the functionality:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Things to try out:
|
||||
|
||||
1. Try signing in and out and see how the file upload page is only accessible when signed in.
|
||||
2. Upload different types of files (images, documents, etc.)
|
||||
3. View and delete files
|
||||
4. Sign in with another account and verify you cannot see files from the first account
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Key Features Implemented
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Storage Bucket" icon="bucket">
|
||||
Dedicated personal storage bucket with proper configuration for user file isolation.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="File Upload Interface" icon="upload">
|
||||
User-friendly upload interface with file selection, preview, and progress feedback.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="File Management" icon="folder">
|
||||
Complete file listing with metadata, viewing capabilities, and deletion functionality.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="File Type Handling" icon="file">
|
||||
Intelligent handling of different file types with appropriate viewing/download behavior.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Error Handling" icon="triangle-exclamation">
|
||||
Comprehensive error handling with user-friendly messages for upload and management operations.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
@@ -1,443 +0,0 @@
|
||||
---
|
||||
title: Get up and running with Nhost and React Native
|
||||
sidebarTitle: React Native
|
||||
description: In this quickstart guide, we'll demonstrate how to build a simple To-Do feature using Nhost and React Native.
|
||||
icon: mobile-notch
|
||||
---
|
||||
|
||||
<Card>
|
||||
Throughout this guide, we'll utilize the **@nhost/react-native-template**, which comes pre-configured with
|
||||
authentication and storage capabilities provided by Nhost.
|
||||
</Card>
|
||||
|
||||
<br />
|
||||
|
||||
<Note>
|
||||
Before starting this quickstart, ensure that your environment is set up to work with React Native.
|
||||
Follow the [setup guide](https://reactnative.dev/docs/next/set-up-your-environment) available on
|
||||
the official React Native website.
|
||||
</Note>
|
||||
|
||||
<Steps>
|
||||
<Step title="Create Nhost Project">
|
||||
Create your project through the [Nhost Dashboard](https://app.nhost.io).
|
||||
</Step>
|
||||
|
||||
<Step title="Setup Database">
|
||||
Navigate to the **SQL Editor** of the database and run the following SQL to create a new table `todos`.
|
||||
|
||||
<Warning>Make sure the option `Track this` is enabled</Warning>
|
||||
|
||||
```sql SQL Editor
|
||||
CREATE TABLE todos (
|
||||
id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
user_id uuid NOT NULL,
|
||||
contents text NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (user_id) REFERENCES auth.users(id) ON UPDATE cascade ON DELETE cascade
|
||||
);
|
||||
```
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Configure the todos table permissions">
|
||||
To set permissions for the new `todos` table, select the table, click on the `...` to open the actions dialog,
|
||||
then click on **Edit Permissions**. Set the following permissions for the `user` role:
|
||||
|
||||
1. `Insert`
|
||||
- Set `Row insert permissions` to `Without any checks`
|
||||
- Select all columns except `user_id` on `Column insert permissions`
|
||||
- Add a new `Column preset` and set `Column Name` to `user_id` and `Column Value` to `X-Hasura-User-Id`
|
||||
- Save
|
||||
|
||||

|
||||
|
||||
2. `Select`
|
||||
- Set `Row select permissions` to `With custom check` and fill in the following rule:
|
||||
- Set `Where` to `todos.user_id`
|
||||
- Set the operator to `_eq`
|
||||
- Set the value to `X-Hasura-User-Id`
|
||||
- Select all columns except `user_id` on `Column select permissions`
|
||||
- Save
|
||||
|
||||

|
||||
|
||||
3. `Update`
|
||||
- Set `Row update permissions` to `With custom check` and fill in the following rule:
|
||||
- Set `Where` to `todos.user_id`
|
||||
- Set the operator to `_eq`
|
||||
- Set the value to `X-Hasura-User-Id`
|
||||
- Select all columns except `user_id` on `Column select permissions`
|
||||
- Save
|
||||
|
||||

|
||||
|
||||
4. `Delete`
|
||||
- Set `Row delete permissions` to `With custom check` and fill in the following rule:
|
||||
- Set `Where` to `todos.user_id`
|
||||
- Set the operator to `_eq`
|
||||
- Set the value to `X-Hasura-User-Id`
|
||||
- Save
|
||||
|
||||

|
||||
</Step>
|
||||
|
||||
<Step title="Configure permissions to enable user file uploads">
|
||||
To enable file uploads by users, set the permissions as follows:
|
||||
|
||||
1. Edit the **files** table permissions
|
||||
1. Navigate to the files table within the [Database tab](https://app.nhost.io/_/_/database/browser/default/storage/files)
|
||||
2. Click on the three dots (...) next to the files table
|
||||
3. Click on **Edit Permissions**
|
||||
|
||||
2. Modify the `Insert` permission for the `user` role:
|
||||
1. Set `Row insert permissions` to `Without any checks`
|
||||
2. Select all columns on `Column insert permissions`
|
||||
4. Save
|
||||
|
||||

|
||||
|
||||
3. `Select`
|
||||
- Set `Row select permissions` to `With custom check` and fill in the following rule:
|
||||
- Set `Where` to `files.uploaded_by_user_id`
|
||||
- Set the operator to `_eq`
|
||||
- Set the value to `X-Hasura-User-Id`
|
||||
- Select all columns on `Column select permissions`
|
||||
- Save
|
||||
|
||||

|
||||
</Step>
|
||||
|
||||
<Step title="Bootstrap your React Native app">
|
||||
Intialize a new React Native project using the template `@nhost/react-native-template`
|
||||
|
||||
```bash Terminal
|
||||
npx react-native init myapp --template @nhost/react-native-template
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step title="Connect your React Native app to the Nhost project">
|
||||
Copy your project's `<subdomain>` and `<region>` values available on the dashboard overview
|
||||
|
||||
```tsx src/root.tsx
|
||||
const nhost = new NhostClient({
|
||||
subdomain: "<subdomain>", // replace the subdomain value e.g. "hjcuuqweqwezolpolrep"
|
||||
region: "<region>", // replace the region value e.g. "eu-central-1"
|
||||
clientStorageType: 'react-native',
|
||||
clientStorage: AsyncStorage,
|
||||
});
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step title="Add the GraphQL queries">
|
||||
Create a new file `src/graphql/todos.ts` that will expose the graphql queries needed to `list`, `add` and `delete` To-Do's.
|
||||
|
||||
```ts src/graphql/todos.ts
|
||||
import {gql} from '@apollo/client';
|
||||
|
||||
export const GET_TODOS = gql`
|
||||
query listTodos {
|
||||
todos(order_by: { created_at: desc }) {
|
||||
id
|
||||
contents
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const ADD_TODO = gql`
|
||||
mutation addTodo($contents: String!) {
|
||||
insert_todos_one(object: { contents: $contents }) {
|
||||
id
|
||||
contents
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const DELETE_TODO = gql`
|
||||
mutation deleteTodo($id: uuid!) {
|
||||
delete_todos_by_pk(id: $id) {
|
||||
__typename
|
||||
}
|
||||
}
|
||||
`;
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step title="Add a form to insert a To-Do">
|
||||
```tsx src/components/AddTodoForm.tsx
|
||||
import React from 'react';
|
||||
import {useMutation} from '@apollo/client';
|
||||
import Button from '@components/Button';
|
||||
import ControlledInput from '@components/ControlledInput';
|
||||
import {ADD_TODO, GET_TODOS} from '@graphql/todos';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {StyleSheet, View} from 'react-native';
|
||||
|
||||
interface AddTodoFormValues {
|
||||
contents: string;
|
||||
}
|
||||
|
||||
export default function AddTodoForm() {
|
||||
const {control, handleSubmit, reset} = useForm<AddTodoFormValues>();
|
||||
|
||||
const [addTodo, {loading}] = useMutation(ADD_TODO, {
|
||||
refetchQueries: [{query: GET_TODOS}],
|
||||
});
|
||||
|
||||
const onSubmit = async (values: AddTodoFormValues) => {
|
||||
const {contents} = values;
|
||||
await addTodo({variables: {contents}});
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<View style={styles.inputWrapper}>
|
||||
<ControlledInput
|
||||
control={control}
|
||||
name="contents"
|
||||
placeholder="New To-Do"
|
||||
autoCapitalize="none"
|
||||
rules={{
|
||||
required: true,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.buttonWrapper}>
|
||||
<Button
|
||||
label="Add"
|
||||
onPress={handleSubmit(onSubmit)}
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
gap: 12,
|
||||
padding: 12,
|
||||
flexDirection: 'row',
|
||||
backgroundColor: 'white',
|
||||
},
|
||||
inputWrapper: {
|
||||
flex: 3,
|
||||
},
|
||||
buttonWrapper: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step title="Add the Todo component and the screen to list all the todos">
|
||||
<CodeGroup>
|
||||
```tsx src/components/Todo.tsx
|
||||
import React from 'react';
|
||||
import {useMutation} from '@apollo/client';
|
||||
import {DELETE_TODO, GET_TODOS} from '@graphql/todos';
|
||||
import {StyleSheet, Text, View} from 'react-native';
|
||||
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||
import Button from './Button';
|
||||
|
||||
export interface TodoItem {
|
||||
id: string;
|
||||
contents: string;
|
||||
}
|
||||
|
||||
export default function Todo({todo: {id, contents}}: {todo: TodoItem}) {
|
||||
const [deleteTodo] = useMutation(DELETE_TODO, {
|
||||
variables: {id},
|
||||
refetchQueries: [{query: GET_TODOS}],
|
||||
});
|
||||
|
||||
const handleDeleteTodo = async () => {
|
||||
await deleteTodo();
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<View style={styles.todoContentWrapper}>
|
||||
<Icon name="check" size={25} />
|
||||
<Text style={styles.todoContent}>{contents}</Text>
|
||||
</View>
|
||||
<View style={styles.buttonWrapper}>
|
||||
<Button
|
||||
label={<Icon name="trash-can-outline" size={20} />}
|
||||
color="#f1f1f1"
|
||||
onPress={handleDeleteTodo}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
padding: 14,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
todoContentWrapper: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 20,
|
||||
},
|
||||
todoContent: {flex: 1},
|
||||
buttonWrapper: {
|
||||
width: 50,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```tsx src/screens/Todos.tsx
|
||||
import React from 'react';
|
||||
import {useQuery} from '@apollo/client';
|
||||
import AddTodoForm from '@components/AddTodoForm';
|
||||
import Todo, {type TodoItem} from '@components/Todo';
|
||||
import {GET_TODOS} from '@graphql/todos';
|
||||
import {useEffect} from 'react';
|
||||
import {ActivityIndicator, FlatList, StyleSheet, View} from 'react-native';
|
||||
|
||||
export default function Todos() {
|
||||
const {loading, data, client} = useQuery<{todos: TodoItem[]}>(GET_TODOS);
|
||||
|
||||
const todos = data?.todos || [];
|
||||
|
||||
useEffect(() => {
|
||||
return () => client.stop();
|
||||
}, [client]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.loadingViewWrapper}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const renderTodo = ({item}: {item: TodoItem}) => <Todo todo={item} />;
|
||||
const itemSeperator = () => <View style={styles.separator} />;
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<AddTodoForm />
|
||||
<FlatList
|
||||
data={todos}
|
||||
keyExtractor={item => item.id}
|
||||
renderItem={renderTodo}
|
||||
ItemSeparatorComponent={itemSeperator}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
loadingViewWrapper: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
wrapper: {
|
||||
flex: 1,
|
||||
backgroundColor: 'white',
|
||||
},
|
||||
separator: {
|
||||
height: 1,
|
||||
backgroundColor: '#f1f1f1',
|
||||
},
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
</Step>
|
||||
|
||||
<Step title="Reference the new Todos components in the Drawer Navigator">
|
||||
```tsx src/screens/Main.tsx
|
||||
function DrawerNavigator() {
|
||||
return (
|
||||
<Drawer.Navigator
|
||||
screenOptions={screenOptions}
|
||||
drawerContent={drawerContent}>
|
||||
<Drawer.Screen name="Profile" component={Profile} />
|
||||
{/* Add the Todos component here */}
|
||||
<Drawer.Screen name="Todos" component={Todos} />
|
||||
<Drawer.Screen name="Storage" component={Storage} />
|
||||
</Drawer.Navigator>
|
||||
);
|
||||
}
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step title="Run the app on the emulator">
|
||||
<Tabs>
|
||||
<Tab title="Android">
|
||||
1. Open a terminal and start the metro bundler
|
||||
```bash Terminal
|
||||
cd myapp
|
||||
npm start
|
||||
```
|
||||
2. Open a new terminal and run the app on Android
|
||||
```bash Terminal
|
||||
cd myapp
|
||||
npm run android
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="iOS">
|
||||
1. Make sure the iOS project cocopods are installed
|
||||
```bash Terminal
|
||||
cd ios
|
||||
pod install
|
||||
```
|
||||
1. Install the `ios-deploy` CLI
|
||||
```bash Terminal
|
||||
npm install -g ios-deploy
|
||||
```
|
||||
2. Start the metro bundler
|
||||
```bash Terminal
|
||||
cd myapp
|
||||
npm start
|
||||
```
|
||||
3. Open a new terminal and run the app on Android
|
||||
```bash Terminal
|
||||
cd myapp
|
||||
npm run ios --interactive
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</Step>
|
||||
|
||||
<Step title="Demo">
|
||||
<iframe
|
||||
width="486"
|
||||
height="864"
|
||||
src="https://www.youtube.com/embed/gfzksbce2G4"
|
||||
title="demo react native"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allowfullscreen
|
||||
/>
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
|
||||
<Note>
|
||||
### Next Steps: enabling Google and Apple Sign-In
|
||||
|
||||
The template is preconfigured to allow users to sign in with Google and Apple. To enable this feature, follow these steps:
|
||||
|
||||
1. Navigate to your Nhost project's [Sign-In Methods settings](https://app.nhost.io/_/_/settings/sign-in-methods).
|
||||
2. Enable Google and/or Apple sign-in.
|
||||
3. Fill in the necessary credentials.
|
||||
|
||||
For detailed instructions on generating the required credentials, refer to the following guides:
|
||||
- [Google Sign-In Guide](https://docs.nhost.io/products/auth/social/sign-in-google)
|
||||
- [Apple Sign-In Guide](https://docs.nhost.io/products/auth/social/sign-in-apple)
|
||||
</Note>
|
||||
121
docs/getting-started/tutorials/reactnative/1-introduction.mdx
Normal file
@@ -0,0 +1,121 @@
|
||||
---
|
||||
title: Create Your Nhost Project
|
||||
description: Learn how to create and set up a new Nhost project to get started building your React Native application
|
||||
sidebarTitle: Create Project
|
||||
icon: plus
|
||||
---
|
||||
|
||||
Welcome to the **Full-Stack React Native Development with Nhost** series! In this comprehensive tutorial series, you'll build a complete React Native application with Nhost that demonstrates authentication, database operations, and file management.
|
||||
|
||||
## About This Tutorial Series
|
||||
|
||||
This tutorial series is divided into **5 parts**, each focusing on a specific aspect of building modern mobile applications with Nhost and React Native. By the end of the series, you'll have built a fully functional application featuring:
|
||||
|
||||
- **User Authentication** - Complete sign up, sign in, and email verification flow
|
||||
- **Todo Management** - Users can create, update, delete, and mark todos as complete
|
||||
- **File Uploads** - Users can upload and manage files with proper permissions
|
||||
- **Protected Routes** - Secure screens that only authenticated users can access
|
||||
|
||||
<Info>
|
||||
This is **Part 1** in the Full-Stack React Native Development with Nhost series. This part sets up the foundation by creating your Nhost project and understanding the series structure.
|
||||
</Info>
|
||||
|
||||
## Full-Stack React Native Development with Nhost
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/reactnative/1-introduction">
|
||||
**Current** - Set up your Nhost project
|
||||
</Card>
|
||||
|
||||
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/reactnative/2-protected-routes">
|
||||
Route protection basics
|
||||
</Card>
|
||||
|
||||
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/reactnative/3-user-authentication">
|
||||
Complete auth flow
|
||||
</Card>
|
||||
|
||||
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/reactnative/4-graphql-operations">
|
||||
CRUD operations with GraphQL
|
||||
</Card>
|
||||
|
||||
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/reactnative/5-file-uploads">
|
||||
File upload and management
|
||||
</Card>
|
||||
|
||||
<Card title="6. Sign in with Apple" icon="apple" href="/getting-started/tutorials/reactnative/6-sign-in-with-apple">
|
||||
Apple authentication integration
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## What You'll Learn
|
||||
|
||||
Throughout this series, you'll master:
|
||||
|
||||
- Setting up and configuring Nhost projects
|
||||
- Implementing secure authentication flows with React Native
|
||||
- Building protected screens with Expo Router
|
||||
- Performing GraphQL queries and mutations
|
||||
- Managing file uploads and storage
|
||||
- Configuring database permissions and security
|
||||
- Building responsive React Native interfaces
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 20+ installed on your machine
|
||||
- Basic knowledge of React Native and JavaScript
|
||||
- Understanding of modern mobile development concepts
|
||||
- Expo CLI installed globally (`npm install -g @expo/cli`)
|
||||
|
||||
Creating an Nhost project is the first step to building your application with Nhost. Let's get started by setting up your backend infrastructure.
|
||||
|
||||
## Step-by-Step Guide
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
|
||||
### Sign Up or Log in
|
||||
|
||||
If you don't have an Nhost account, sign up at [Nhost](https://app.nhost.io/). If you already have an account, log in.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Create a New Project
|
||||
|
||||
Click on the "Create Project" button on your dashboard or follow the onboarding prompts if you're a new user.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Take note of your project subdomain and region
|
||||
|
||||
Take note of your project subdomain and region. You will need this information to connect your application to the Nhost backend in upcoming tutorials.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
## What's Next?
|
||||
|
||||
With your Nhost project created, you now have access to:
|
||||
|
||||
- [**PostgreSQL Database**](/products/database/overview) - For storing your application data
|
||||
- [**Authentication Service**](/products/auth/overview) - For managing users and sessions
|
||||
- [**GraphQL API**](/products/graphql/overview) - For querying and mutating data
|
||||
- [**File Storage**](/products/storage/overview) - For uploading and managing files
|
||||
- [**Functions**](/products/functions/overview) - For running serverless functions
|
||||
|
||||
In the [next tutorial](/getting-started/tutorials/reactnative/2-protected-routes), you'll start building your React Native application and learn how to protect screens based on user authentication status.
|
||||
|
||||
<Tip>
|
||||
Keep your project subdomain and region handy - you'll need them throughout the series to connect your React Native application to the Nhost backend.
|
||||
</Tip>
|
||||
1499
docs/getting-started/tutorials/reactnative/2-protected-routes.mdx
Normal file
@@ -0,0 +1,805 @@
|
||||
---
|
||||
title: User Authentication in React Native
|
||||
description: Learn how to implement user authentication in a React Native application using Nhost
|
||||
sidebarTitle: "User Authentication"
|
||||
icon: user
|
||||
---
|
||||
|
||||
This tutorial part builds upon the [Protected Screens part](/getting-started/tutorials/reactnative/2-protected-routes) by adding complete email/password authentication with email verification functionality. You'll implement sign up, sign in, email verification, and sign out features to create a full authentication flow.
|
||||
|
||||
<Info>
|
||||
This is **Part 3** in the Full-Stack React Native Development with Nhost series. This part creates a production-ready authentication system with secure email verification and proper error handling.
|
||||
</Info>
|
||||
|
||||
## Full-Stack React Native Development with Nhost
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/reactnative/1-introduction">
|
||||
Set up your Nhost project
|
||||
</Card>
|
||||
|
||||
<Card title="2. Protected Screens" icon="lock" href="/getting-started/tutorials/reactnative/2-protected-routes">
|
||||
Screen protection basics
|
||||
</Card>
|
||||
|
||||
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/reactnative/3-user-authentication">
|
||||
**Current** - Complete auth flow
|
||||
</Card>
|
||||
|
||||
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/reactnative/4-graphql-operations">
|
||||
CRUD operations with GraphQL
|
||||
</Card>
|
||||
|
||||
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/reactnative/5-file-uploads">
|
||||
File upload and management
|
||||
</Card>
|
||||
|
||||
<Card title="6. Sign in with Apple" icon="apple" href="/getting-started/tutorials/reactnative/6-sign-in-with-apple">
|
||||
Apple authentication integration
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Complete the [Protected Screens part](/getting-started/tutorials/reactnative/2-protected-routes) first
|
||||
- The project from the previous part set up and running
|
||||
|
||||
## Step-by-Step Guide
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
|
||||
### Create the Sign In Screen
|
||||
|
||||
Build a comprehensive sign-in form with proper error handling and loading states. This screen handles user authentication and includes special logic for post-verification sign-in.
|
||||
|
||||
```tsx app/signin.tsx lines
|
||||
import { Link, router } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useAuth } from "./lib/nhost/AuthProvider";
|
||||
import { commonStyles } from "./styles/commonStyles";
|
||||
import { colors } from "./styles/theme";
|
||||
|
||||
export default function SignIn() {
|
||||
const { nhost, isAuthenticated } = useAuth();
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Use useEffect for navigation after authentication is confirmed
|
||||
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,
|
||||
});
|
||||
|
||||
// If we have a session, sign in was successful
|
||||
if (response.body?.session) {
|
||||
router.replace("/profile");
|
||||
} else {
|
||||
setError("Failed to sign in. Please check your credentials.");
|
||||
}
|
||||
} catch (err) {
|
||||
const message = (err as Error).message || "Unknown error";
|
||||
setError(`An error occurred during sign in: ${message}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={commonStyles.container}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={commonStyles.centerContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Text style={commonStyles.title}>Sign In</Text>
|
||||
|
||||
<View style={commonStyles.card}>
|
||||
<View style={commonStyles.formField}>
|
||||
<Text style={commonStyles.labelText}>Email</Text>
|
||||
<TextInput
|
||||
style={commonStyles.input}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
placeholder="Enter your email"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={commonStyles.formField}>
|
||||
<Text style={commonStyles.labelText}>Password</Text>
|
||||
<TextInput
|
||||
style={commonStyles.input}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
placeholder="Enter your password"
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<View style={commonStyles.errorContainer}>
|
||||
<Text style={commonStyles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[commonStyles.button, commonStyles.fullWidth]}
|
||||
onPress={handleSubmit}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="small" color={colors.surface} />
|
||||
) : (
|
||||
<Text style={commonStyles.buttonText}>Sign In</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={commonStyles.linkContainer}>
|
||||
<Text style={commonStyles.linkText}>
|
||||
Don't have an account?{" "}
|
||||
<Link href="/signup" style={commonStyles.link}>
|
||||
Sign Up
|
||||
</Link>
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Create the Sign Up Screen
|
||||
|
||||
Implement user registration with email verification flow. This screen collects user information, creates accounts, and guides users through the email verification process.
|
||||
|
||||
```tsx app/signup.tsx lines
|
||||
import * as Linking from "expo-linking";
|
||||
import { Link, router } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useAuth } from "./lib/nhost/AuthProvider";
|
||||
import { commonStyles } from "./styles/commonStyles";
|
||||
import { colors } from "./styles/theme";
|
||||
|
||||
export default function SignUp() {
|
||||
const { nhost, isAuthenticated } = useAuth();
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
// Redirect authenticated users to profile
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
router.replace("/profile");
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
try {
|
||||
const response = await nhost.auth.signUpEmailPassword({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
displayName,
|
||||
// Set the redirect URL for email verification
|
||||
redirectTo: Linking.createURL("verify"),
|
||||
},
|
||||
});
|
||||
|
||||
if (response.body?.session) {
|
||||
// Successfully signed up and automatically signed in
|
||||
router.replace("/profile");
|
||||
} else {
|
||||
// Verification email sent
|
||||
setSuccess(true);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = (err as Error).message || "Unknown error";
|
||||
setError(`An error occurred during sign up: ${message}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<View style={commonStyles.centerContent}>
|
||||
<Text style={commonStyles.title}>Check Your Email</Text>
|
||||
<View style={commonStyles.successContainer}>
|
||||
<Text style={commonStyles.successText}>
|
||||
We've sent a verification link to{" "}
|
||||
<Text style={commonStyles.emailText}>{email}</Text>
|
||||
</Text>
|
||||
<Text style={[commonStyles.bodyText, commonStyles.textCenter]}>
|
||||
Please check your email and click the verification link to activate
|
||||
your account.
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[commonStyles.button, commonStyles.fullWidth]}
|
||||
onPress={() => router.replace("/signin")}
|
||||
>
|
||||
<Text style={commonStyles.buttonText}>Back to Sign In</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={commonStyles.container}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={commonStyles.centerContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Text style={commonStyles.title}>Sign Up</Text>
|
||||
|
||||
<View style={commonStyles.card}>
|
||||
<View style={commonStyles.formField}>
|
||||
<Text style={commonStyles.labelText}>Display Name</Text>
|
||||
<TextInput
|
||||
style={commonStyles.input}
|
||||
value={displayName}
|
||||
onChangeText={setDisplayName}
|
||||
placeholder="Enter your name"
|
||||
autoCapitalize="words"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={commonStyles.formField}>
|
||||
<Text style={commonStyles.labelText}>Email</Text>
|
||||
<TextInput
|
||||
style={commonStyles.input}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
placeholder="Enter your email"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={commonStyles.formField}>
|
||||
<Text style={commonStyles.labelText}>Password</Text>
|
||||
<TextInput
|
||||
style={commonStyles.input}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
placeholder="Enter your password"
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<Text style={commonStyles.helperText}>Minimum 8 characters</Text>
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<View style={commonStyles.errorContainer}>
|
||||
<Text style={commonStyles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[commonStyles.button, commonStyles.fullWidth]}
|
||||
onPress={handleSubmit}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="small" color={colors.surface} />
|
||||
) : (
|
||||
<Text style={commonStyles.buttonText}>Sign Up</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={commonStyles.linkContainer}>
|
||||
<Text style={commonStyles.linkText}>
|
||||
Already have an account?{" "}
|
||||
<Link href="/signin" style={commonStyles.link}>
|
||||
Sign In
|
||||
</Link>
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Create the Email Verification Screen
|
||||
|
||||
Build a dedicated verification screen that processes email verification tokens. This screen handles the verification flow when users click the email verification link.
|
||||
|
||||
```tsx app/verify.tsx lines
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ActivityIndicator, Text, TouchableOpacity, View } from "react-native";
|
||||
import { useAuth } from "./lib/nhost/AuthProvider";
|
||||
import { commonStyles } from "./styles/commonStyles";
|
||||
import { colors } from "./styles/theme";
|
||||
|
||||
export default function Verify() {
|
||||
const params = useLocalSearchParams();
|
||||
|
||||
const [status, setStatus] = useState<"verifying" | "success" | "error">(
|
||||
"verifying",
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [urlParams, setUrlParams] = useState<Record<string, string>>({});
|
||||
|
||||
const { nhost } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
// Extract the refresh token from the URL
|
||||
const refreshToken = params.refreshToken as string;
|
||||
|
||||
if (!refreshToken) {
|
||||
// Collect all URL parameters to display for debugging
|
||||
const allParams: Record<string, string> = {};
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (typeof value === "string") {
|
||||
allParams[key] = value;
|
||||
}
|
||||
});
|
||||
setUrlParams(allParams);
|
||||
|
||||
setStatus("error");
|
||||
setError("No refresh token found in URL");
|
||||
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;
|
||||
}
|
||||
});
|
||||
setUrlParams(allParams);
|
||||
|
||||
setStatus("error");
|
||||
setError("No refresh token found in URL");
|
||||
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) {
|
||||
const message = (err as Error).message || "Unknown error";
|
||||
if (!isMounted) return;
|
||||
|
||||
setStatus("error");
|
||||
setError(`An error occurred during verification: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
processToken();
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [params, nhost.auth]);
|
||||
|
||||
return (
|
||||
<View style={commonStyles.centerContent}>
|
||||
<Text style={commonStyles.title}>Email Verification</Text>
|
||||
|
||||
<View style={commonStyles.card}>
|
||||
{status === "verifying" && (
|
||||
<View style={commonStyles.alignCenter}>
|
||||
<Text style={[commonStyles.bodyText, commonStyles.marginBottom]}>
|
||||
Verifying your email...
|
||||
</Text>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{status === "success" && (
|
||||
<View style={commonStyles.alignCenter}>
|
||||
<Text style={commonStyles.successText}>
|
||||
✓ Successfully verified!
|
||||
</Text>
|
||||
<Text style={commonStyles.bodyText}>
|
||||
You'll be redirected to your profile page shortly...
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<View style={commonStyles.alignCenter}>
|
||||
<Text style={commonStyles.errorText}>Verification failed</Text>
|
||||
<Text style={[commonStyles.bodyText, commonStyles.marginBottom]}>
|
||||
{error}
|
||||
</Text>
|
||||
|
||||
{Object.keys(urlParams).length > 0 && (
|
||||
<View style={commonStyles.debugContainer}>
|
||||
<Text style={commonStyles.debugTitle}>URL Parameters:</Text>
|
||||
{Object.entries(urlParams).map(([key, value]) => (
|
||||
<View key={key} style={commonStyles.debugItem}>
|
||||
<Text style={commonStyles.debugKey}>{key}:</Text>
|
||||
<Text style={commonStyles.debugValue}>{value}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[commonStyles.button, commonStyles.fullWidth]}
|
||||
onPress={() => router.replace("/signin")}
|
||||
>
|
||||
<Text style={commonStyles.buttonText}>Back to Sign In</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
<Warning>
|
||||
**Important Configuration Required:** Before testing email verification, you must configure your Nhost project's authentication settings:
|
||||
|
||||
1. Go to your Nhost project dashboard
|
||||
2. Navigate to **Settings → Authentication**
|
||||
3. Add your Expo development URL to the **Allowed Redirect URLs** field:
|
||||
- For Expo Go: `exp://x.x.x.x:8081/` (replace with your local IP)
|
||||
4. Ensure your production domain is also added when deploying
|
||||
|
||||
Without this configuration, you'll receive a `redirectTo not allowed` error when users attempt to sign up or verify their email addresses.
|
||||
</Warning>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Update Home Screen
|
||||
|
||||
Update the home screen to include navigation to authentication screens and a button to sign out.
|
||||
|
||||
```tsx app/index.tsx lines highlight={2, 8-31, 61-78}
|
||||
import { useRouter } from "expo-router";
|
||||
import { Alert, Text, TouchableOpacity, View } from "react-native";
|
||||
import { useAuth } from "./lib/nhost/AuthProvider";
|
||||
import { commonStyles, homeStyles } from "./styles/commonStyles";
|
||||
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, session, nhost, user } = useAuth();
|
||||
|
||||
const handleSignOut = async () => {
|
||||
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Sign Out",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
if (session) {
|
||||
await nhost.auth.signOut({
|
||||
refreshToken: session.refreshToken,
|
||||
});
|
||||
}
|
||||
router.replace("/");
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
Alert.alert("Error", `Failed to sign out: ${message}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={commonStyles.centerContent}>
|
||||
<Text style={commonStyles.title}>Welcome to Nhost React Native Demo</Text>
|
||||
|
||||
<View style={homeStyles.welcomeCard}>
|
||||
{isAuthenticated ? (
|
||||
<View style={{ gap: 15, width: "100%" }}>
|
||||
<Text style={homeStyles.welcomeText}>
|
||||
Hello, {user?.displayName || user?.email}!
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[commonStyles.button, commonStyles.fullWidth]}
|
||||
onPress={() => router.push("/profile")}
|
||||
>
|
||||
<Text style={commonStyles.buttonText}>Go to Profile</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[commonStyles.button, { backgroundColor: "#ef4444" }]}
|
||||
onPress={handleSignOut}
|
||||
>
|
||||
<Text style={commonStyles.buttonText}>Sign Out</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<Text style={homeStyles.authMessage}>You are not signed in.</Text>
|
||||
|
||||
<View style={{ gap: 15, width: "100%" }}>
|
||||
<TouchableOpacity
|
||||
style={[commonStyles.button, commonStyles.fullWidth]}
|
||||
onPress={() => router.push("/signin")}
|
||||
>
|
||||
<Text style={commonStyles.buttonText}>Sign In</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
commonStyles.button,
|
||||
commonStyles.buttonSecondary,
|
||||
commonStyles.fullWidth,
|
||||
]}
|
||||
onPress={() => router.push("/signup")}
|
||||
>
|
||||
<Text style={commonStyles.buttonText}>Sign Up</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Add Sign Out Functionality">
|
||||
|
||||
Update the profile screen to include sign-out functionality:
|
||||
|
||||
```tsx app/profile.tsx lines highlight={1-2,8-36,103-108}
|
||||
import { useRouter } from "expo-router";
|
||||
import { Alert, ScrollView, Text, TouchableOpacity, View } from "react-native";
|
||||
import ProtectedScreen from "./components/ProtectedScreen";
|
||||
import { useAuth } from "./lib/nhost/AuthProvider";
|
||||
import { commonStyles, profileStyles } from "./styles/commonStyles";
|
||||
|
||||
export default function Profile() {
|
||||
const router = useRouter();
|
||||
const { user, session, nhost } = useAuth();
|
||||
|
||||
const handleSignOut = async () => {
|
||||
Alert.alert(
|
||||
"Sign Out",
|
||||
"Are you sure you want to sign out?",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Sign Out",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
if (session) {
|
||||
await nhost.auth.signOut({
|
||||
refreshToken: session.refreshToken,
|
||||
});
|
||||
}
|
||||
router.replace("/");
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
Alert.alert("Error", `Failed to sign out: ${message}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ProtectedScreen>
|
||||
<ScrollView
|
||||
style={commonStyles.container}
|
||||
contentContainerStyle={commonStyles.contentContainer}
|
||||
>
|
||||
<Text style={commonStyles.title}>Your Profile</Text>
|
||||
|
||||
<View style={commonStyles.card}>
|
||||
<Text style={commonStyles.cardTitle}>User Information</Text>
|
||||
|
||||
<View style={profileStyles.profileItem}>
|
||||
<Text style={commonStyles.labelText}>Display Name:</Text>
|
||||
<Text style={commonStyles.valueText}>
|
||||
{user?.displayName || "Not set"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={profileStyles.profileItem}>
|
||||
<Text style={commonStyles.labelText}>Email:</Text>
|
||||
<Text style={commonStyles.valueText}>
|
||||
{user?.email || "Not available"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={profileStyles.profileItem}>
|
||||
<Text style={commonStyles.labelText}>User ID:</Text>
|
||||
<Text
|
||||
style={commonStyles.valueText}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="middle"
|
||||
>
|
||||
{user?.id || "Not available"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={profileStyles.profileItem}>
|
||||
<Text style={commonStyles.labelText}>Roles:</Text>
|
||||
<Text style={commonStyles.valueText}>
|
||||
{user?.roles?.join(", ") || "None"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={[profileStyles.profileItem, profileStyles.profileItemLast]}>
|
||||
<Text style={commonStyles.labelText}>Email Verified:</Text>
|
||||
<Text style={[
|
||||
commonStyles.valueText,
|
||||
user?.emailVerified ? commonStyles.successText : commonStyles.errorText
|
||||
]}>
|
||||
{user?.emailVerified ? "✓ Yes" : "✗ No"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={commonStyles.card}>
|
||||
<Text style={commonStyles.cardTitle}>Session Information</Text>
|
||||
<View style={commonStyles.sessionInfo}>
|
||||
<Text
|
||||
style={commonStyles.sessionValue}
|
||||
>
|
||||
{JSON.stringify(session, null, 2)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[commonStyles.button, { backgroundColor: "#ef4444" }]}
|
||||
onPress={handleSignOut}
|
||||
>
|
||||
<Text style={commonStyles.buttonText}>Sign Out</Text>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
</ProtectedScreen>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Run and Test the Application
|
||||
|
||||
Start your development server and test the complete authentication flow to ensure everything works properly.
|
||||
|
||||
```bash
|
||||
npm run start
|
||||
```
|
||||
|
||||
Things to try out:
|
||||
|
||||
1. **Sign Up Flow**: Try signing up with a new email address. Check your email for the verification link and click it. See how you are sent to the verification screen and then redirected to your profile.
|
||||
|
||||
2. **Sign In/Out**: Try signing out and then signing back in with the same credentials.
|
||||
|
||||
3. **Navigation**: Notice how the home screen shows different options based on authentication state - showing "Sign In" and "Sign Up" buttons when logged out, and "Go to Profile" when logged in.
|
||||
|
||||
4. **Error Handling**: Try signing in with invalid credentials to see error handling, or try signing up with a short password to see validation.
|
||||
|
||||
5. **Email Verification**: Test the complete email verification flow in both development and when deployed.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Key Features Demonstrated
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Complete Registration Flow" icon="user-plus">
|
||||
Full email/password registration with proper form validation and user feedback optimized for mobile devices.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Email Verification" icon="envelope-circle-check">
|
||||
Custom `/verify` screen that securely processes email verification tokens with Expo Linking integration.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Error Handling" icon="triangle-exclamation">
|
||||
Comprehensive error handling for unverified emails, failed authentication, and network issues with mobile-friendly alerts.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Session Management" icon="clock">
|
||||
Complete sign out functionality with confirmation dialogs and proper session state management across the application.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## React Native Adaptations Made
|
||||
|
||||
This tutorial demonstrates several key React Native patterns:
|
||||
|
||||
- **KeyboardAvoidingView**: Ensures forms remain accessible when keyboard is open
|
||||
- **ActivityIndicator**: Native loading spinners for better performance
|
||||
- **Alert.alert()**: Native confirmation dialogs for important actions
|
||||
- **Expo Linking**: Proper deep linking for email verification
|
||||
- **TouchableOpacity**: Native touch feedback for buttons
|
||||
- **ScrollView**: Scrollable content containers with proper keyboard handling
|
||||
@@ -0,0 +1,881 @@
|
||||
---
|
||||
title: GraphQL Operations in React Native
|
||||
description: Learn how to perform GraphQL operations and manage database permissions while building a complete todos application with Nhost and React Native
|
||||
sidebarTitle: "GraphQL Operations"
|
||||
icon: code
|
||||
---
|
||||
|
||||
This part builds upon the previous parts by demonstrating how to perform GraphQL operations with proper database permissions. You'll learn how to design database tables, configure user permissions, and implement complete CRUD operations through GraphQL queries and mutations in a real todos application.
|
||||
|
||||
<Info>
|
||||
This is **Part 4** in the Full-Stack React Native Development with Nhost series. This part focuses on GraphQL operations, database management, and permission-based data access control in a production application.
|
||||
</Info>
|
||||
|
||||
## Full-Stack React Native Development with Nhost
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/reactnative/1-introduction">
|
||||
Set up your Nhost project
|
||||
</Card>
|
||||
|
||||
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/reactnative/2-protected-routes">
|
||||
Route protection basics
|
||||
</Card>
|
||||
|
||||
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/reactnative/3-user-authentication">
|
||||
Complete auth flow
|
||||
</Card>
|
||||
|
||||
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/reactnative/4-graphql-operations">
|
||||
**Current** - CRUD operations with GraphQL
|
||||
</Card>
|
||||
|
||||
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/reactnative/5-file-uploads">
|
||||
File upload and management
|
||||
</Card>
|
||||
|
||||
<Card title="6. Sign in with Apple" icon="apple" href="/getting-started/tutorials/reactnative/6-sign-in-with-apple">
|
||||
Apple authentication integration
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Complete the [User Authentication part](/getting-started/tutorials/reactnative/3-user-authentication) first
|
||||
- The project from the previous part set up and running
|
||||
|
||||
## What You'll Build
|
||||
|
||||
By the end of this part, you'll have:
|
||||
- **GraphQL queries and mutations** for complete CRUD operations
|
||||
- **Database schema** with proper relationships and constraints
|
||||
- **User permissions** for secure data access control
|
||||
- **React Native screens** that interact with GraphQL endpoint
|
||||
|
||||
## Step-by-Step Guide
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
|
||||
### Create the To-Dos Table
|
||||
|
||||
First, we'll perform the database changes to set up the todos table with proper schema and relationships to users.
|
||||
|
||||
In your Nhost project dashboard:
|
||||
1. Navigate to **Database**
|
||||
2. Click on the SQL Editor
|
||||
|
||||
Enter the following SQL:
|
||||
|
||||
<Tabs>
|
||||
|
||||
<Tab title="SQL">
|
||||
|
||||
```sql
|
||||
CREATE TABLE public.todos (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
created_at timestamptz DEFAULT now() NOT NULL,
|
||||
updated_at timestamptz DEFAULT now() NOT NULL,
|
||||
title text NOT NULL,
|
||||
details text,
|
||||
completed bool DEFAULT false NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (user_id) REFERENCES auth.users (id) ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
|
||||
CREATE TRIGGER update_todos_updated_at
|
||||
BEFORE UPDATE ON public.todos
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
```
|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab title="UI">
|
||||

|
||||
</Tab>
|
||||
|
||||
|
||||
</Tabs>
|
||||
|
||||
<Warning>
|
||||
Please make sure to enable **Track this** so that the new table todos is available through the auto-generated APIs
|
||||
</Warning>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Set Up Permissions
|
||||
|
||||
It’s now time to set permission rules for the table you just created. With the table `todos` selected, click on **…**, followed by **Edit Permissions**.
|
||||
You will set permissions for the **user** role and actions **insert**, **select**, **update**, and **delete**.
|
||||
|
||||
<Tabs>
|
||||
|
||||
<Tab title="Insert">
|
||||
|
||||
When inserting permissions we are only allowing users to set the `title`, `details`, and `completed` columns as the rest of the columns are set automatically by the backend. The `user_id` column is configured as a preset to the currently authenticated user's ID using the `X-Hasura-User-Id` session variable. This ensures that each todo is associated with the user who created it.
|
||||
|
||||
|
||||

|
||||
</Tab>
|
||||
|
||||
<Tab title="Select">
|
||||
|
||||
For selecting (reading) todos, we are allowing to read all columns but only for rows where the `user_id` matches the authenticated user's ID. This ensures that users can only see their own todos.
|
||||
|
||||

|
||||
</Tab>
|
||||
|
||||
<Tab title="Update">
|
||||
|
||||
When updating todos, we are allowing users to modify the `title`, `details`, and `completed` columns but only for rows where the `user_id` matches their own ID. This prevents users from modifying todos that do not belong to them.
|
||||
|
||||

|
||||
</Tab>
|
||||
|
||||
<Tab title="Delete">
|
||||
|
||||
For deleting todos, we are allowing users to delete rows only where the `user_id` matches their own ID. This ensures that users cannot delete todos that belong to other users.
|
||||
|
||||

|
||||
</Tab>
|
||||
|
||||
</Tabs>
|
||||
|
||||
</Step>
|
||||
|
||||
|
||||
<Step>
|
||||
|
||||
### Create the Todos Screen Component
|
||||
|
||||
Now let's implement the React Native screen that uses the database we just configured.
|
||||
|
||||
```tsx app/todos.tsx lines
|
||||
import { router, Stack } from "expo-router";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
FlatList,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import ProtectedScreen from "./components/ProtectedScreen";
|
||||
import { useAuth } from "./lib/nhost/AuthProvider";
|
||||
import { commonStyles } from "./styles/commonStyles";
|
||||
|
||||
// The interfaces below define the structure of our data
|
||||
// They are not strictly necessary but help with type safety
|
||||
|
||||
// Represents a single todo item
|
||||
interface Todo {
|
||||
id: string;
|
||||
title: string;
|
||||
details: string | null;
|
||||
completed: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
// This matches the GraphQL response structure for fetching todos
|
||||
// Can be used as a generic type on the request method
|
||||
interface GetTodos {
|
||||
todos: Todo[];
|
||||
}
|
||||
|
||||
// This matches the GraphQL response structure for inserting a todo
|
||||
// Can be used as a generic type on the request method
|
||||
interface InsertTodo {
|
||||
insert_todos_one: Todo | null;
|
||||
}
|
||||
|
||||
// This matches the GraphQL response structure for updating a todo
|
||||
// Can be used as a generic type on the request method
|
||||
interface UpdateTodo {
|
||||
update_todos_by_pk: Todo | null;
|
||||
}
|
||||
|
||||
export default function Todos() {
|
||||
const { nhost, session } = useAuth();
|
||||
const [todos, setTodos] = useState<Todo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [newTodoTitle, setNewTodoTitle] = useState("");
|
||||
const [newTodoDetails, setNewTodoDetails] = useState("");
|
||||
const [editingTodo, setEditingTodo] = useState<Todo | null>(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [expandedTodos, setExpandedTodos] = useState<Set<string>>(new Set());
|
||||
const [addingTodo, setAddingTodo] = useState(false);
|
||||
const [updatingTodos, setUpdatingTodos] = useState<Set<string>>(new Set());
|
||||
|
||||
// Redirect to sign in if not authenticated
|
||||
useEffect(() => {
|
||||
if (!session) {
|
||||
router.replace("/signin");
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
const fetchTodos = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// Make GraphQL request to fetch todos using Nhost client
|
||||
// The query automatically filters by user_id due to Hasura permissions
|
||||
const response = await nhost.graphql.request<GetTodos>({
|
||||
query: `
|
||||
query GetTodos {
|
||||
todos(order_by: { created_at: desc }) {
|
||||
id
|
||||
title
|
||||
details
|
||||
completed
|
||||
created_at
|
||||
updated_at
|
||||
user_id
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
// Check for GraphQL errors in the response body
|
||||
if (response.body.errors) {
|
||||
throw new Error(
|
||||
response.body.errors[0]?.message || "Failed to fetch todos",
|
||||
);
|
||||
}
|
||||
|
||||
// Extract todos from the GraphQL response data
|
||||
setTodos(response.body?.data?.todos || []);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch todos");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [nhost.graphql]);
|
||||
|
||||
const addTodo = async () => {
|
||||
if (!newTodoTitle.trim()) return;
|
||||
|
||||
try {
|
||||
setAddingTodo(true);
|
||||
// Execute GraphQL mutation to insert a new todo
|
||||
// user_id is automatically set by Hasura based on JWT token
|
||||
const response = await nhost.graphql.request<InsertTodo>({
|
||||
query: `
|
||||
mutation InsertTodo($title: String!, $details: String) {
|
||||
insert_todos_one(object: { title: $title, details: $details }) {
|
||||
id
|
||||
title
|
||||
details
|
||||
completed
|
||||
created_at
|
||||
updated_at
|
||||
user_id
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
title: newTodoTitle.trim(),
|
||||
details: newTodoDetails.trim() || null,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.body.errors) {
|
||||
throw new Error(
|
||||
response.body.errors[0]?.message || "Failed to add todo",
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.body?.data?.insert_todos_one) {
|
||||
throw new Error("Failed to add todo");
|
||||
}
|
||||
setTodos([response.body?.data?.insert_todos_one, ...todos]);
|
||||
setNewTodoTitle("");
|
||||
setNewTodoDetails("");
|
||||
setShowAddForm(false);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to add todo");
|
||||
Alert.alert(
|
||||
"Error",
|
||||
err instanceof Error ? err.message : "Failed to add todo",
|
||||
);
|
||||
} finally {
|
||||
setAddingTodo(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateTodo = async (
|
||||
id: string,
|
||||
updates: Partial<Pick<Todo, "title" | "details" | "completed">>,
|
||||
) => {
|
||||
try {
|
||||
setUpdatingTodos((prev) => new Set([...prev, id]));
|
||||
// Execute GraphQL mutation to update an existing todo by primary key
|
||||
// Hasura permissions ensure users can only update their own todos
|
||||
const response = await nhost.graphql.request<UpdateTodo>({
|
||||
query: `
|
||||
mutation UpdateTodo($id: uuid!, $updates: todos_set_input!) {
|
||||
update_todos_by_pk(pk_columns: { id: $id }, _set: $updates) {
|
||||
id
|
||||
title
|
||||
details
|
||||
completed
|
||||
created_at
|
||||
updated_at
|
||||
user_id
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
id,
|
||||
updates,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.body.errors) {
|
||||
throw new Error(
|
||||
response.body.errors[0]?.message || "Failed to update todo",
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.body?.data?.update_todos_by_pk) {
|
||||
throw new Error("Failed to update todo");
|
||||
}
|
||||
|
||||
const updatedTodo = response.body?.data?.update_todos_by_pk;
|
||||
if (updatedTodo) {
|
||||
setTodos(todos.map((todo) => (todo.id === id ? updatedTodo : todo)));
|
||||
}
|
||||
setEditingTodo(null);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to update todo");
|
||||
Alert.alert(
|
||||
"Error",
|
||||
err instanceof Error ? err.message : "Failed to update todo",
|
||||
);
|
||||
} finally {
|
||||
setUpdatingTodos((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(id);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTodo = async (id: string) => {
|
||||
Alert.alert("Delete Todo", "Are you sure you want to delete this todo?", [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
setUpdatingTodos((prev) => new Set([...prev, id]));
|
||||
// Execute GraphQL mutation to delete a todo by primary key
|
||||
// Hasura permissions ensure users can only delete their own todos
|
||||
const response = await nhost.graphql.request({
|
||||
query: `
|
||||
mutation DeleteTodo($id: uuid!) {
|
||||
delete_todos_by_pk(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.body.errors) {
|
||||
throw new Error(
|
||||
response.body.errors[0]?.message || "Failed to delete todo",
|
||||
);
|
||||
}
|
||||
|
||||
setTodos(todos.filter((todo) => todo.id !== id));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to delete todo",
|
||||
);
|
||||
Alert.alert(
|
||||
"Error",
|
||||
err instanceof Error ? err.message : "Failed to delete todo",
|
||||
);
|
||||
} finally {
|
||||
setUpdatingTodos((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(id);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const toggleComplete = async (todo: Todo) => {
|
||||
await updateTodo(todo.id, { completed: !todo.completed });
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!editingTodo) return;
|
||||
await updateTodo(editingTodo.id, {
|
||||
title: editingTodo.title,
|
||||
details: editingTodo.details,
|
||||
});
|
||||
};
|
||||
|
||||
const toggleTodoExpansion = (todoId: string) => {
|
||||
const newExpanded = new Set(expandedTodos);
|
||||
if (newExpanded.has(todoId)) {
|
||||
newExpanded.delete(todoId);
|
||||
} else {
|
||||
newExpanded.add(todoId);
|
||||
}
|
||||
setExpandedTodos(newExpanded);
|
||||
};
|
||||
|
||||
// Fetch todos when user session is available
|
||||
// The session contains the JWT token needed for GraphQL authentication
|
||||
useEffect(() => {
|
||||
if (session) {
|
||||
fetchTodos();
|
||||
}
|
||||
}, [session, fetchTodos]);
|
||||
|
||||
if (!session) {
|
||||
return null; // Will redirect to sign in
|
||||
}
|
||||
|
||||
const renderTodoItem = ({ item: todo }: { item: Todo }) => {
|
||||
const isUpdating = updatingTodos.has(todo.id);
|
||||
const isExpanded = expandedTodos.has(todo.id);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
commonStyles.todoCard,
|
||||
todo.completed && commonStyles.todoCompleted,
|
||||
]}
|
||||
>
|
||||
{editingTodo?.id === todo.id ? (
|
||||
<View style={commonStyles.todoEditForm}>
|
||||
<Text style={commonStyles.inputLabel}>Title</Text>
|
||||
<TextInput
|
||||
style={commonStyles.input}
|
||||
value={editingTodo.title}
|
||||
onChangeText={(text) =>
|
||||
setEditingTodo({
|
||||
...editingTodo,
|
||||
title: text,
|
||||
})
|
||||
}
|
||||
placeholder="Enter todo title"
|
||||
/>
|
||||
<Text style={commonStyles.inputLabel}>Details</Text>
|
||||
<TextInput
|
||||
style={[commonStyles.input, commonStyles.textArea]}
|
||||
value={editingTodo.details || ""}
|
||||
onChangeText={(text) =>
|
||||
setEditingTodo({
|
||||
...editingTodo,
|
||||
details: text,
|
||||
})
|
||||
}
|
||||
placeholder="Enter details (optional)"
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
/>
|
||||
<View style={commonStyles.buttonGroup}>
|
||||
<TouchableOpacity
|
||||
style={[commonStyles.button, commonStyles.primaryButton]}
|
||||
onPress={saveEdit}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<Text style={commonStyles.buttonText}>
|
||||
{isUpdating ? "Saving..." : "Save"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[commonStyles.button, commonStyles.secondaryButton]}
|
||||
onPress={() => setEditingTodo(null)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
commonStyles.buttonText,
|
||||
commonStyles.secondaryButtonText,
|
||||
]}
|
||||
>
|
||||
Cancel
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View>
|
||||
<View style={commonStyles.todoHeader}>
|
||||
<TouchableOpacity
|
||||
style={commonStyles.todoTitleContainer}
|
||||
onPress={() => toggleTodoExpansion(todo.id)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
commonStyles.todoTitle,
|
||||
todo.completed && commonStyles.todoTitleCompleted,
|
||||
]}
|
||||
>
|
||||
{todo.title}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={commonStyles.todoActions}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
commonStyles.actionButton,
|
||||
commonStyles.completeButton,
|
||||
]}
|
||||
onPress={() => toggleComplete(todo)}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<Text style={commonStyles.actionButtonText}>
|
||||
{isUpdating ? "⌛" : todo.completed ? "↶" : "✓"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[commonStyles.actionButton, commonStyles.editButton]}
|
||||
onPress={() => setEditingTodo(todo)}
|
||||
>
|
||||
<Text style={commonStyles.actionButtonText}>✏️</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[commonStyles.actionButton, commonStyles.deleteButton]}
|
||||
onPress={() => deleteTodo(todo.id)}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<Text style={commonStyles.actionButtonText}>🗑️</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
{isExpanded && (
|
||||
<View style={commonStyles.todoDetails}>
|
||||
{todo.details && (
|
||||
<Text
|
||||
style={[
|
||||
commonStyles.todoDescription,
|
||||
todo.completed && commonStyles.todoDescriptionCompleted,
|
||||
]}
|
||||
>
|
||||
{todo.details}
|
||||
</Text>
|
||||
)}
|
||||
<View style={commonStyles.todoMeta}>
|
||||
<Text style={commonStyles.metaText}>
|
||||
Created: {new Date(todo.created_at).toLocaleString()}
|
||||
</Text>
|
||||
<Text style={commonStyles.metaText}>
|
||||
Updated: {new Date(todo.updated_at).toLocaleString()}
|
||||
</Text>
|
||||
{todo.completed && (
|
||||
<View style={commonStyles.completionBadge}>
|
||||
<Text style={commonStyles.completionText}>
|
||||
✅ Completed
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderHeader = () => (
|
||||
<>
|
||||
<View style={commonStyles.pageHeader}>
|
||||
<Text style={commonStyles.pageTitle}>My Todos</Text>
|
||||
{!showAddForm && (
|
||||
<TouchableOpacity
|
||||
style={commonStyles.addButton}
|
||||
onPress={() => setShowAddForm(true)}
|
||||
>
|
||||
<Text style={commonStyles.addButtonText}>+</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<View style={[commonStyles.errorContainer, { marginHorizontal: 16 }]}>
|
||||
<Text style={commonStyles.errorText}>Error: {error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{showAddForm && (
|
||||
<View style={[commonStyles.card, { marginHorizontal: 16 }]}>
|
||||
<Text style={commonStyles.cardTitle}>Add New Todo</Text>
|
||||
<View style={commonStyles.formFields}>
|
||||
<View style={commonStyles.fieldGroup}>
|
||||
<Text style={commonStyles.inputLabel}>Title *</Text>
|
||||
<TextInput
|
||||
style={commonStyles.input}
|
||||
value={newTodoTitle}
|
||||
onChangeText={setNewTodoTitle}
|
||||
placeholder="What needs to be done?"
|
||||
/>
|
||||
</View>
|
||||
<View style={commonStyles.fieldGroup}>
|
||||
<Text style={commonStyles.inputLabel}>Details</Text>
|
||||
<TextInput
|
||||
style={[commonStyles.input, commonStyles.textArea]}
|
||||
value={newTodoDetails}
|
||||
onChangeText={setNewTodoDetails}
|
||||
placeholder="Add some details (optional)..."
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
/>
|
||||
</View>
|
||||
<View style={commonStyles.buttonGroup}>
|
||||
<TouchableOpacity
|
||||
style={[commonStyles.button, commonStyles.primaryButton]}
|
||||
onPress={addTodo}
|
||||
disabled={addingTodo || !newTodoTitle.trim()}
|
||||
>
|
||||
<Text style={commonStyles.buttonText}>
|
||||
{addingTodo ? "Adding..." : "Add Todo"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[commonStyles.button, commonStyles.secondaryButton]}
|
||||
onPress={() => {
|
||||
setShowAddForm(false);
|
||||
setNewTodoTitle("");
|
||||
setNewTodoDetails("");
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
commonStyles.buttonText,
|
||||
commonStyles.secondaryButtonText,
|
||||
]}
|
||||
>
|
||||
Cancel
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const renderEmptyState = () => (
|
||||
<View style={commonStyles.emptyState}>
|
||||
<Text style={commonStyles.emptyStateTitle}>No todos yet</Text>
|
||||
<Text style={commonStyles.emptyStateText}>
|
||||
Create your first todo to get started!
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ProtectedScreen>
|
||||
<Stack.Screen options={{ title: "My Todos" }} />
|
||||
<View style={commonStyles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#6366f1" />
|
||||
<Text style={commonStyles.loadingText}>Loading todos...</Text>
|
||||
</View>
|
||||
</ProtectedScreen>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedScreen>
|
||||
<Stack.Screen options={{ title: "My Todos" }} />
|
||||
<View style={commonStyles.container}>
|
||||
<FlatList
|
||||
data={showAddForm ? [] : todos}
|
||||
renderItem={renderTodoItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
ListHeaderComponent={renderHeader}
|
||||
ListEmptyComponent={!showAddForm ? renderEmptyState : null}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={commonStyles.listContainer}
|
||||
/>
|
||||
</View>
|
||||
</ProtectedScreen>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Update Home Screen Navigation
|
||||
|
||||
Since React Native uses file-based routing with Expo Router, you can add navigation to the todos screen from your home screen or any other screen using the `router.push()` method.
|
||||
|
||||
```tsx app/index.tsx lines highlight={44-49}
|
||||
import { useRouter } from "expo-router";
|
||||
import { Alert, Text, TouchableOpacity, View } from "react-native";
|
||||
import { useAuth } from "./lib/nhost/AuthProvider";
|
||||
import { commonStyles, homeStyles } from "./styles/commonStyles";
|
||||
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, session, nhost, user } = useAuth();
|
||||
|
||||
const handleSignOut = async () => {
|
||||
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Sign Out",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
if (session) {
|
||||
await nhost.auth.signOut({
|
||||
refreshToken: session.refreshToken,
|
||||
});
|
||||
}
|
||||
router.replace("/");
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
Alert.alert("Error", `Failed to sign out: ${message}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={commonStyles.centerContent}>
|
||||
<Text style={commonStyles.title}>Welcome to Nhost React Native Demo</Text>
|
||||
|
||||
<View style={homeStyles.welcomeCard}>
|
||||
{isAuthenticated ? (
|
||||
<View style={{ gap: 15, width: "100%" }}>
|
||||
<Text style={homeStyles.welcomeText}>
|
||||
Hello, {user?.displayName || user?.email}!
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[commonStyles.button, commonStyles.fullWidth]}
|
||||
onPress={() => router.push("/todos")}
|
||||
>
|
||||
<Text style={commonStyles.buttonText}>My Todos</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[commonStyles.button, commonStyles.fullWidth]}
|
||||
onPress={() => router.push("/profile")}
|
||||
>
|
||||
<Text style={commonStyles.buttonText}>Go to Profile</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[commonStyles.button, { backgroundColor: "#ef4444" }]}
|
||||
onPress={handleSignOut}
|
||||
>
|
||||
<Text style={commonStyles.buttonText}>Sign Out</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<Text style={homeStyles.authMessage}>You are not signed in.</Text>
|
||||
|
||||
<View style={{ gap: 15, width: "100%" }}>
|
||||
<TouchableOpacity
|
||||
style={[commonStyles.button, commonStyles.fullWidth]}
|
||||
onPress={() => router.push("/signin")}
|
||||
>
|
||||
<Text style={commonStyles.buttonText}>Sign In</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
commonStyles.button,
|
||||
commonStyles.buttonSecondary,
|
||||
commonStyles.fullWidth,
|
||||
]}
|
||||
onPress={() => router.push("/signup")}
|
||||
>
|
||||
<Text style={commonStyles.buttonText}>Sign Up</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Test Your Complete Application
|
||||
|
||||
Run your React Native application and test all the functionality:
|
||||
|
||||
```bash
|
||||
npx expo start
|
||||
```
|
||||
|
||||
Things to try out:
|
||||
|
||||
1. Try signing in and out and see how the Todos screen is only available when authenticated
|
||||
2. Create, view, edit, complete, and delete todos. See how the UI updates accordingly with React Native animations
|
||||
3. Test the mobile experience - long press, swipe gestures, and keyboard interactions
|
||||
4. Test on different devices/simulators to see how the todos look on various screen sizes
|
||||
5. Try signing out and signing in with a different account to verify that you cannot see or modify todos from other accounts
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Key Features Implemented
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Database Schema" icon="database">
|
||||
Properly designed todos table with constraints, indexes, and automatic timestamp updates for optimal performance.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="GraphQL API" icon="webhook">
|
||||
Auto-generated GraphQL API with queries and mutations for full CRUD operations on todos.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Row-Level Security" icon="shield-check">
|
||||
Comprehensive permissions ensuring users can only access their own todos through all GraphQL operations.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="CRUD Operations" icon="arrows-rotate">
|
||||
Complete Create, Read, Update, Delete functionality with proper error handling and user feedback.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Native Mobile Interface" icon="sparkles">
|
||||
Touch-friendly expandable todo items, inline editing with React Native keyboard handling, completion status, and detailed timestamps optimized for mobile screens.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
776
docs/getting-started/tutorials/reactnative/5-file-uploads.mdx
Normal file
@@ -0,0 +1,776 @@
|
||||
---
|
||||
title: File Uploads in React Native
|
||||
description: Learn how to implement file upload functionality with storage buckets and permissions while building a complete file management system with Nhost and React Native
|
||||
sidebarTitle: "File Uploads"
|
||||
icon: upload
|
||||
---
|
||||
|
||||
This part builds upon the previous GraphQL operations part by demonstrating how to implement file upload functionality with proper storage permissions. You'll learn how to create storage buckets, configure upload permissions, and implement complete file management operations in a React Native application.
|
||||
|
||||
<Info>
|
||||
This is **Part 5** in the Full-Stack React Native Development with Nhost series. This part focuses on file storage, upload operations, and permission-based file access control in a production application.
|
||||
</Info>
|
||||
|
||||
## Full-Stack React Native Development with Nhost
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/reactnative/1-introduction">
|
||||
Set up your Nhost project
|
||||
</Card>
|
||||
|
||||
<Card title="2. Protected Screens" icon="lock" href="/getting-started/tutorials/reactnative/2-protected-routes">
|
||||
Screen protection basics
|
||||
</Card>
|
||||
|
||||
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/reactnative/3-user-authentication">
|
||||
Complete auth flow
|
||||
</Card>
|
||||
|
||||
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/reactnative/4-graphql-operations">
|
||||
CRUD operations with GraphQL
|
||||
</Card>
|
||||
|
||||
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/reactnative/5-file-uploads">
|
||||
**Current** - File upload and management
|
||||
</Card>
|
||||
|
||||
<Card title="6. Sign in with Apple" icon="apple" href="/getting-started/tutorials/reactnative/6-sign-in-with-apple">
|
||||
Apple authentication integration
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Complete the [GraphQL Operations part](/getting-started/tutorials/reactnative/4-graphql-operations) first
|
||||
- The project from the previous part set up and running
|
||||
|
||||
## What You'll Build
|
||||
|
||||
By the end of this part, you'll have:
|
||||
- A **personal bucket** so users can upload their own private files
|
||||
- **File upload functionality**
|
||||
- **File management interface** for viewing and deleting files
|
||||
- **Security permissions** ensuring users can only access their own files
|
||||
|
||||
## Step-by-Step Guide
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
|
||||
### Create a Personal Storage Bucket
|
||||
|
||||
First, we'll create a storage bucket where users can upload their personal files.
|
||||
|
||||
In your Nhost project dashboard:
|
||||
1. Navigate to **Database**
|
||||
2. Change to **schema.storage**, then buckets
|
||||
3. Now click on `+ Insert` on the top right corner.
|
||||
4. As id set `personal`, leave the rest of the fields blank and click on Insert at the bottom
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Configure Storage Permissions
|
||||
|
||||
Now we need to set up permissions for the storage bucket to ensure the `user` role can only upload, view, and delete their own files.
|
||||
|
||||
<Tabs>
|
||||
|
||||
<Tab title="Upload">
|
||||
|
||||
To upload files we need to grant permissions to insert on the table `storage.files`. Because we want to allow uploading files only to the `personal` bucket we will be using the `bucket_id eq personal` as a custom check. In addition, we are configuring a preset `uploaded_by_user_id = X-Hasura-User-id`, this will automatically extract the user_id from the session and set the column accordingly. Then we can use this in other permissions to allow downloading files and deleting them.
|
||||
|
||||

|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab title="Download">
|
||||
|
||||
To download files users need to be able to query those files. To make sure users can only download files they uploaded we will be leveraging the column `uploaded_by_user_id` column from before and the `bucket_id``.
|
||||
|
||||

|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab title="Delete">
|
||||
|
||||
Similarly to downloading files, to delete files users need to be able to delete rows from the `storage.files` table. Again we will use the `uploaded_by_user_id` column and the `bucket_id` to make sure users can only delete their own files.
|
||||
|
||||

|
||||
|
||||
</Tab>
|
||||
|
||||
</Tabs>
|
||||
|
||||
<Info>
|
||||
You can read more about storage permissions [here](/products/storage/overview#permissions)
|
||||
</Info>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Install Required Dependencies
|
||||
|
||||
First, let's install the dependencies needed for file handling in React Native.
|
||||
|
||||
```bash
|
||||
npx expo install expo-document-picker@13 expo-file-system@18 expo-sharing@13
|
||||
```
|
||||
|
||||
- **expo-document-picker**: For selecting files from device storage
|
||||
- **expo-file-system**: For handling file system operations
|
||||
- **expo-sharing**: For sharing/opening files with other apps
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Create the File Upload Screen Component
|
||||
|
||||
Now let's implement the React Native screen for file upload functionality using the shared theme from the protected routes tutorial.
|
||||
|
||||
```tsx app/files.tsx lines
|
||||
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,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import ProtectedScreen from "./components/ProtectedScreen";
|
||||
import { useAuth } from "./lib/nhost/AuthProvider";
|
||||
import { commonStyles, fileUploadStyles } from "./styles/commonStyles";
|
||||
import { colors } from "./styles/theme";
|
||||
|
||||
interface DeleteStatus {
|
||||
message: string;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
interface GraphqlGetFilesResponse {
|
||||
files: FileMetadata[];
|
||||
}
|
||||
|
||||
// Utility function to format file size
|
||||
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]}`;
|
||||
}
|
||||
|
||||
// Convert Blob to Base64 for React Native file handling
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
export default function Files() {
|
||||
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 [isViewingInProgress, setIsViewingInProgress] =
|
||||
useState<boolean>(false);
|
||||
|
||||
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 () => {
|
||||
// Prevent DocumentPicker from opening if we're currently viewing a file
|
||||
if (isViewingInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 to the personal bucket
|
||||
// The uploadedByUserId is automatically set by the storage permissions
|
||||
const response = await nhost.storage.uploadFiles({
|
||||
"bucket-id": "personal",
|
||||
"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);
|
||||
setIsViewingInProgress(true);
|
||||
|
||||
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 timestamp = Date.now();
|
||||
const tempFileName = fileName.includes(".")
|
||||
? fileName
|
||||
: `${fileName}.file`;
|
||||
const tempFilePath = `${FileSystem.cacheDirectory}${timestamp}_${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
|
||||
});
|
||||
|
||||
// Clean up the temp file after sharing
|
||||
try {
|
||||
await FileSystem.deleteAsync(tempFilePath, { idempotent: true });
|
||||
} catch (cleanupErr) {
|
||||
console.warn("Failed to clean up temp file:", cleanupErr);
|
||||
}
|
||||
|
||||
// Add a delay before allowing new document picker actions
|
||||
// This prevents iOS from triggering file selection dialogs
|
||||
setTimeout(() => {
|
||||
setIsViewingInProgress(false);
|
||||
}, 1000);
|
||||
} 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}`);
|
||||
setIsViewingInProgress(false);
|
||||
} 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
|
||||
// Permissions ensure users can only delete their own files
|
||||
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={commonStyles.container}>
|
||||
{/* Upload Form */}
|
||||
<View style={commonStyles.card}>
|
||||
<Text style={commonStyles.cardTitle}>Upload a File</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
style={fileUploadStyles.fileUpload}
|
||||
onPress={pickDocument}
|
||||
>
|
||||
<View style={fileUploadStyles.uploadIcon}>
|
||||
<Text style={fileUploadStyles.uploadIconText}>⬆️</Text>
|
||||
</View>
|
||||
<Text style={fileUploadStyles.uploadText}>
|
||||
Tap to select a file
|
||||
</Text>
|
||||
{selectedFile &&
|
||||
!selectedFile.canceled &&
|
||||
selectedFile.assets?.[0] && (
|
||||
<Text style={fileUploadStyles.fileName}>
|
||||
{selectedFile.assets[0].name} (
|
||||
{formatFileSize(selectedFile.assets[0].size || 0)})
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{error && (
|
||||
<View style={commonStyles.errorContainer}>
|
||||
<Text style={commonStyles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{uploadResult && (
|
||||
<View style={commonStyles.successContainer}>
|
||||
<Text style={commonStyles.successText}>
|
||||
File uploaded successfully!
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
commonStyles.button,
|
||||
(!selectedFile || selectedFile.canceled || uploading) &&
|
||||
fileUploadStyles.buttonDisabled,
|
||||
]}
|
||||
onPress={handleUpload}
|
||||
disabled={!selectedFile || selectedFile.canceled || uploading}
|
||||
>
|
||||
<Text style={commonStyles.buttonText}>
|
||||
{uploading ? "Uploading..." : "Upload File"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Files List */}
|
||||
<View style={commonStyles.card}>
|
||||
<Text style={commonStyles.cardTitle}>Your Files</Text>
|
||||
|
||||
{deleteStatus && (
|
||||
<View
|
||||
style={[
|
||||
deleteStatus.isError
|
||||
? commonStyles.errorContainer
|
||||
: commonStyles.successContainer,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={
|
||||
deleteStatus.isError
|
||||
? commonStyles.errorText
|
||||
: commonStyles.successText
|
||||
}
|
||||
>
|
||||
{deleteStatus.message}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isFetching ? (
|
||||
<View style={commonStyles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
<Text style={commonStyles.loadingText}>Loading files...</Text>
|
||||
</View>
|
||||
) : files.length === 0 ? (
|
||||
<View style={fileUploadStyles.emptyState}>
|
||||
<Text style={fileUploadStyles.emptyIcon}>📄</Text>
|
||||
<Text style={fileUploadStyles.emptyTitle}>No files yet</Text>
|
||||
<Text style={fileUploadStyles.emptyDescription}>
|
||||
Upload your first file to get started!
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={files}
|
||||
keyExtractor={(item) => item.id || Math.random().toString()}
|
||||
renderItem={({ item }) => (
|
||||
<View style={fileUploadStyles.fileItem}>
|
||||
<View style={fileUploadStyles.fileInfo}>
|
||||
<Text
|
||||
style={fileUploadStyles.fileNameText}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
<Text style={fileUploadStyles.fileDetails}>
|
||||
{item.mimeType} • {formatFileSize(item.size || 0)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={fileUploadStyles.fileActions}>
|
||||
<TouchableOpacity
|
||||
style={fileUploadStyles.actionButton}
|
||||
onPress={() =>
|
||||
handleViewFile(
|
||||
item.id || "unknown",
|
||||
item.name || "unknown",
|
||||
item.mimeType || "unknown",
|
||||
)
|
||||
}
|
||||
disabled={viewingFile === item.id}
|
||||
>
|
||||
{viewingFile === item.id ? (
|
||||
<Text style={fileUploadStyles.actionText}>⌛</Text>
|
||||
) : (
|
||||
<Text style={fileUploadStyles.actionText}>👁️</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
fileUploadStyles.actionButton,
|
||||
fileUploadStyles.deleteButton,
|
||||
]}
|
||||
onPress={() => handleDeleteFile(item.id || "unknown")}
|
||||
disabled={deleting === item.id}
|
||||
>
|
||||
{deleting === item.id ? (
|
||||
<Text style={fileUploadStyles.actionText}>⌛</Text>
|
||||
) : (
|
||||
<Text style={fileUploadStyles.actionText}>🗑️</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
style={fileUploadStyles.fileList}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</ProtectedScreen>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Update Home Screen Navigation
|
||||
|
||||
Update the home screen to include navigation to the new file upload screen.
|
||||
|
||||
```tsx app/index.tsx lines highlight={51-56}
|
||||
import { useRouter } from "expo-router";
|
||||
import { Alert, Text, TouchableOpacity, View } from "react-native";
|
||||
import { useAuth } from "./lib/nhost/AuthProvider";
|
||||
import { commonStyles, homeStyles } from "./styles/commonStyles";
|
||||
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, session, nhost, user } = useAuth();
|
||||
|
||||
const handleSignOut = async () => {
|
||||
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Sign Out",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
if (session) {
|
||||
await nhost.auth.signOut({
|
||||
refreshToken: session.refreshToken,
|
||||
});
|
||||
}
|
||||
router.replace("/");
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
Alert.alert("Error", `Failed to sign out: ${message}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={commonStyles.centerContent}>
|
||||
<Text style={commonStyles.title}>Welcome to Nhost React Native Demo</Text>
|
||||
|
||||
<View style={homeStyles.welcomeCard}>
|
||||
{isAuthenticated ? (
|
||||
<View style={{ gap: 15, width: "100%" }}>
|
||||
<Text style={homeStyles.welcomeText}>
|
||||
Hello, {user?.displayName || user?.email}!
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[commonStyles.button, commonStyles.fullWidth]}
|
||||
onPress={() => router.push("/todos")}
|
||||
>
|
||||
<Text style={commonStyles.buttonText}>My Todos</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[commonStyles.button, commonStyles.fullWidth]}
|
||||
onPress={() => router.push("/files")}
|
||||
>
|
||||
<Text style={commonStyles.buttonText}>My Files</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[commonStyles.button, commonStyles.fullWidth]}
|
||||
onPress={() => router.push("/profile")}
|
||||
>
|
||||
<Text style={commonStyles.buttonText}>Go to Profile</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[commonStyles.button, { backgroundColor: "#ef4444" }]}
|
||||
onPress={handleSignOut}
|
||||
>
|
||||
<Text style={commonStyles.buttonText}>Sign Out</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<Text style={homeStyles.authMessage}>You are not signed in.</Text>
|
||||
|
||||
<View style={{ gap: 15, width: "100%" }}>
|
||||
<TouchableOpacity
|
||||
style={[commonStyles.button, commonStyles.fullWidth]}
|
||||
onPress={() => router.push("/signin")}
|
||||
>
|
||||
<Text style={commonStyles.buttonText}>Sign In</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
commonStyles.button,
|
||||
commonStyles.buttonSecondary,
|
||||
commonStyles.fullWidth,
|
||||
]}
|
||||
onPress={() => router.push("/signup")}
|
||||
>
|
||||
<Text style={commonStyles.buttonText}>Sign Up</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Test Your File Upload System
|
||||
|
||||
Run your React Native application and test all the functionality:
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
Things to try out:
|
||||
|
||||
1. Try signing in and out and see how the file upload screen is only accessible when signed in.
|
||||
2. Upload different types of files (images, documents, PDFs, etc.)
|
||||
3. View files using the native sharing functionality
|
||||
4. Delete files with confirmation dialogs
|
||||
5. Sign in with another account and verify you cannot see files from the first account
|
||||
6. Test on both iOS and Android if available
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Key Features Implemented
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Personal Storage Bucket" icon="bucket">
|
||||
Dedicated personal storage bucket with proper configuration for user file isolation using the "personal" bucket.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Native File Upload Interface" icon="upload">
|
||||
React Native file selection using Expo DocumentPicker with support for all file types and visual feedback.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Mobile File Management" icon="folder">
|
||||
Complete file listing with FlatList, file metadata display, native viewing, and deletion with confirmation dialogs.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Native File Handling" icon="file">
|
||||
Platform-specific file handling using Expo FileSystem and Sharing for viewing files with appropriate native apps.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Row-Level Security" icon="shield-check">
|
||||
Comprehensive storage permissions ensuring users can only upload, view, and delete their own files through GraphQL and storage APIs.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Error Handling & UX" icon="triangle-exclamation">
|
||||
Native Alert dialogs, loading states, and user-friendly error messages optimized for mobile interfaces.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
@@ -0,0 +1,684 @@
|
||||
---
|
||||
title: Sign in with Apple in React Native
|
||||
description: Learn how to implement Sign in with Apple authentication in your React Native app using Nhost Auth with proper configuration and native integration
|
||||
sidebarTitle: "Sign in with Apple"
|
||||
icon: apple
|
||||
---
|
||||
|
||||
This tutorial extends the authentication system built in the previous parts by adding Sign in with Apple functionality. You'll learn how to configure Apple Sign In in the Nhost dashboard, implement the native iOS integration, and handle the authentication flow in your React Native application.
|
||||
|
||||
<Info>
|
||||
This is **Part 6** in the Full-Stack React Native Development with Nhost series. This part focuses on integrating Apple's native authentication system with your existing Nhost authentication flow.
|
||||
</Info>
|
||||
|
||||
## Full-Stack React Native Development with Nhost
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/reactnative/1-introduction">
|
||||
Set up your Nhost project
|
||||
</Card>
|
||||
|
||||
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/reactnative/2-protected-routes">
|
||||
Route protection basics
|
||||
</Card>
|
||||
|
||||
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/reactnative/3-user-authentication">
|
||||
Complete auth flow
|
||||
</Card>
|
||||
|
||||
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/reactnative/4-graphql-operations">
|
||||
CRUD operations with GraphQL
|
||||
</Card>
|
||||
|
||||
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/reactnative/5-file-uploads">
|
||||
File upload and management
|
||||
</Card>
|
||||
|
||||
<Card title="6. Sign in with Apple" icon="apple" href="/getting-started/tutorials/reactnative/6-sign-in-with-apple">
|
||||
**Current** - Apple authentication integration
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Complete the [User Authentication part](/getting-started/tutorials/reactnative/3-user-authentication) first
|
||||
- Access to an Apple Developer account (required for Sign in with Apple)
|
||||
- An iOS device or simulator for testing (Sign in with Apple requires iOS)
|
||||
- The project from the previous parts set up and running
|
||||
|
||||
## What You'll Build
|
||||
|
||||
By the end of this part, you'll have:
|
||||
- **Apple Developer Console configuration** for Sign in with Apple
|
||||
- **Nhost Auth provider setup** for Apple authentication
|
||||
- **Native Sign in with Apple button** in your React Native app
|
||||
- **Seamless integration** with your existing authentication flow
|
||||
- **Error handling** for Apple Sign In specific scenarios
|
||||
|
||||
## Step-by-Step Guide
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
|
||||
### Configure Apple Developer Console
|
||||
|
||||
Before we start we need to configure Apple Sign In. To do so, follow the [sign in with Apple](/products/auth/social/sign-in-apple) guide. In addition, you will have to configure the audience in the nhost dashboard. To do so, set the audience with your application's bundle identifier (e.g. `com.yourcompany.yourapp`).
|
||||
|
||||
<Warning>
|
||||
If you are testing this flow in Expo Go, you will need to configure the audience to be `host.exp.Exponent`.
|
||||
</Warning>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Install Required Dependencies
|
||||
|
||||
Install the necessary packages for Apple Sign In in React Native:
|
||||
|
||||
```bash
|
||||
npx expo install expo-apple-authentication@7 expo-crypto@14
|
||||
```
|
||||
|
||||
The required packages provide:
|
||||
- **expo-apple-authentication**: Native Apple Sign In functionality and Apple-styled sign-in buttons
|
||||
- **expo-crypto**: Cryptographic utilities needed for secure nonce generation and hashing
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Update App Configuration
|
||||
|
||||
Add the Sign in with Apple configuration to your `app.json` or `app.config.js`:
|
||||
|
||||
```json app.json lines highlight={11-23,52}
|
||||
{
|
||||
"expo": {
|
||||
"name": "nhost-reactnative-tutorial",
|
||||
"slug": "nhost-reactnative-tutorial",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "nhostreactnativetutorial",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "io.example.nhost-reactnative-tutorial",
|
||||
"jsEngine": "jsc",
|
||||
"infoPlist": {
|
||||
"NSFaceIDUsageDescription": "This app uses Face ID for signing in",
|
||||
"CFBundleURLTypes": [
|
||||
{
|
||||
"CFBundleURLSchemes": ["nhost-reactnative-tutorial"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false
|
||||
},
|
||||
"web": {
|
||||
"output": "static",
|
||||
"favicon": "./assets/images/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"imageWidth": 200,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff",
|
||||
"dark": {
|
||||
"backgroundColor": "#000000"
|
||||
}
|
||||
}
|
||||
],
|
||||
["expo-apple-authentication"]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
"reactCompiler": true
|
||||
},
|
||||
"extra": {
|
||||
"NHOST_REGION": <region>,
|
||||
"NHOST_SUBDOMAIN": <subdomain>
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<Important>
|
||||
Make sure the `bundleIdentifier` matches the App ID you configured in the Apple Developer Console, and set `usesAppleSignIn` to `true`.
|
||||
</Important>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Create Apple Sign In Component
|
||||
|
||||
Create a reusable component for Apple Sign In functionality following the ReactNativeDemo approach:
|
||||
|
||||
```tsx app/components/AppleSignInButton.tsx
|
||||
import * as AppleAuthentication from "expo-apple-authentication";
|
||||
import * as Crypto from "expo-crypto";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, Platform, StyleSheet } from "react-native";
|
||||
import { useAuth } from "../lib/nhost/AuthProvider";
|
||||
|
||||
interface AppleSignInButtonProps {
|
||||
isLoading: boolean;
|
||||
setIsLoading: (isLoading: boolean) => void;
|
||||
}
|
||||
|
||||
export default function AppleSignInButton({
|
||||
setIsLoading,
|
||||
}: AppleSignInButtonProps) {
|
||||
const { nhost } = useAuth();
|
||||
const router = useRouter();
|
||||
const [appleAuthAvailable, setAppleAuthAvailable] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAvailability = async () => {
|
||||
if (Platform.OS === "ios") {
|
||||
const isAvailable = await AppleAuthentication.isAvailableAsync();
|
||||
setAppleAuthAvailable(isAvailable);
|
||||
}
|
||||
};
|
||||
|
||||
void checkAvailability();
|
||||
}, []);
|
||||
|
||||
const handleAppleSignIn = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// Generate a random nonce for security
|
||||
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 user cancellation gracefully
|
||||
if (error instanceof Error && error.message.includes("canceled")) {
|
||||
// User cancelled the sign-in flow, don't show an error
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle other errors
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to authenticate 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,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Update Sign In Screen
|
||||
|
||||
Now integrate the Apple Sign In button into your existing sign-in screen following the tutorial structure:
|
||||
|
||||
```tsx app/signin.tsx lines highlight={13,70-81}
|
||||
import { Link, router } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import AppleSignInButton from "./components/AppleSignInButton";
|
||||
import { useAuth } from "./lib/nhost/AuthProvider";
|
||||
import { commonStyles } from "./styles/commonStyles";
|
||||
import { colors } from "./styles/theme";
|
||||
|
||||
export default function SignIn() {
|
||||
const { nhost, isAuthenticated } = useAuth();
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Use useEffect for navigation after authentication is confirmed
|
||||
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,
|
||||
});
|
||||
|
||||
// If we have a session, sign in was successful
|
||||
if (response.body?.session) {
|
||||
router.replace("/profile");
|
||||
} else {
|
||||
setError("Failed to sign in. Please check your credentials.");
|
||||
}
|
||||
} catch (err) {
|
||||
const message = (err as Error).message || "Unknown error";
|
||||
setError(`An error occurred during sign in: ${message}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={commonStyles.container}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={commonStyles.centerContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Text style={commonStyles.title}>Sign In</Text>
|
||||
|
||||
<View style={commonStyles.card}>
|
||||
{/* Apple Sign In Button */}
|
||||
<AppleSignInButton
|
||||
isLoading={isLoading}
|
||||
setIsLoading={setIsLoading}
|
||||
/>
|
||||
|
||||
{/* Divider */}
|
||||
<View style={commonStyles.dividerContainer}>
|
||||
<View style={commonStyles.divider} />
|
||||
<Text style={commonStyles.dividerText}>or</Text>
|
||||
<View style={commonStyles.divider} />
|
||||
</View>
|
||||
|
||||
<View style={commonStyles.formField}>
|
||||
<Text style={commonStyles.labelText}>Email</Text>
|
||||
<TextInput
|
||||
style={commonStyles.input}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
placeholder="Enter your email"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={commonStyles.formField}>
|
||||
<Text style={commonStyles.labelText}>Password</Text>
|
||||
<TextInput
|
||||
style={commonStyles.input}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
placeholder="Enter your password"
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<View style={commonStyles.errorContainer}>
|
||||
<Text style={commonStyles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[commonStyles.button, commonStyles.fullWidth]}
|
||||
onPress={handleSubmit}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="small" color={colors.surface} />
|
||||
) : (
|
||||
<Text style={commonStyles.buttonText}>Sign In</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={commonStyles.linkContainer}>
|
||||
<Text style={commonStyles.linkText}>
|
||||
Don't have an account?{" "}
|
||||
<Link href="/signup" style={commonStyles.link}>
|
||||
Sign Up
|
||||
</Link>
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Update Sign Up Screen
|
||||
|
||||
Also add Apple Sign In to your sign-up screen for consistency:
|
||||
|
||||
```tsx app/signup.tsx lines highlight={14,103-114}
|
||||
import * as Linking from "expo-linking";
|
||||
import { Link, router } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import AppleSignInButton from "./components/AppleSignInButton";
|
||||
import { useAuth } from "./lib/nhost/AuthProvider";
|
||||
import { commonStyles } from "./styles/commonStyles";
|
||||
import { colors } from "./styles/theme";
|
||||
|
||||
export default function SignUp() {
|
||||
const { nhost, isAuthenticated } = useAuth();
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
// Redirect authenticated users to profile
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
router.replace("/profile");
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
try {
|
||||
const response = await nhost.auth.signUpEmailPassword({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
displayName,
|
||||
// Set the redirect URL for email verification
|
||||
redirectTo: Linking.createURL("verify"),
|
||||
},
|
||||
});
|
||||
|
||||
if (response.body?.session) {
|
||||
// Successfully signed up and automatically signed in
|
||||
router.replace("/profile");
|
||||
} else {
|
||||
// Verification email sent
|
||||
setSuccess(true);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = (err as Error).message || "Unknown error";
|
||||
setError(`An error occurred during sign up: ${message}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<View style={commonStyles.centerContent}>
|
||||
<Text style={commonStyles.title}>Check Your Email</Text>
|
||||
<View style={commonStyles.successContainer}>
|
||||
<Text style={commonStyles.successText}>
|
||||
We've sent a verification link to{" "}
|
||||
<Text style={commonStyles.emailText}>{email}</Text>
|
||||
</Text>
|
||||
<Text style={[commonStyles.bodyText, commonStyles.textCenter]}>
|
||||
Please check your email and click the verification link to activate
|
||||
your account.
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[commonStyles.button, commonStyles.fullWidth]}
|
||||
onPress={() => router.replace("/signin")}
|
||||
>
|
||||
<Text style={commonStyles.buttonText}>Back to Sign In</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={commonStyles.container}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={commonStyles.centerContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Text style={commonStyles.title}>Sign Up</Text>
|
||||
|
||||
<View style={commonStyles.card}>
|
||||
{/* Apple Sign In Button */}
|
||||
<AppleSignInButton
|
||||
isLoading={isLoading}
|
||||
setIsLoading={setIsLoading}
|
||||
/>
|
||||
|
||||
{/* Divider */}
|
||||
<View style={commonStyles.dividerContainer}>
|
||||
<View style={commonStyles.divider} />
|
||||
<Text style={commonStyles.dividerText}>or</Text>
|
||||
<View style={commonStyles.divider} />
|
||||
</View>
|
||||
|
||||
<View style={commonStyles.formField}>
|
||||
<Text style={commonStyles.labelText}>Display Name</Text>
|
||||
<TextInput
|
||||
style={commonStyles.input}
|
||||
value={displayName}
|
||||
onChangeText={setDisplayName}
|
||||
placeholder="Enter your name"
|
||||
autoCapitalize="words"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={commonStyles.formField}>
|
||||
<Text style={commonStyles.labelText}>Email</Text>
|
||||
<TextInput
|
||||
style={commonStyles.input}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
placeholder="Enter your email"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={commonStyles.formField}>
|
||||
<Text style={commonStyles.labelText}>Password</Text>
|
||||
<TextInput
|
||||
style={commonStyles.input}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
placeholder="Enter your password"
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<Text style={commonStyles.helperText}>Minimum 8 characters</Text>
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<View style={commonStyles.errorContainer}>
|
||||
<Text style={commonStyles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[commonStyles.button, commonStyles.fullWidth]}
|
||||
onPress={handleSubmit}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="small" color={colors.surface} />
|
||||
) : (
|
||||
<Text style={commonStyles.buttonText}>Sign Up</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={commonStyles.linkContainer}>
|
||||
<Text style={commonStyles.linkText}>
|
||||
Already have an account?{" "}
|
||||
<Link href="/signin" style={commonStyles.link}>
|
||||
Sign In
|
||||
</Link>
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Test Sign in with Apple
|
||||
|
||||
Build and test your application on an iOS device or simulator:
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
Things to test:
|
||||
|
||||
1. **Apple Sign In Flow**: Tap the "Sign in with Apple" button and complete the authentication
|
||||
2. **User Creation**: Sign in with Apple for the first time to create a new user account
|
||||
3. **Returning Users**: Sign in again with the same Apple ID to verify user recognition
|
||||
4. **Profile Information**: Check that user name and email are properly populated
|
||||
5. **Integration**: Verify that Apple Sign In users can access todos, files, and other protected features
|
||||
6. **Error Handling**: Test network errors and authentication failures
|
||||
|
||||
<Note>
|
||||
Sign in with Apple only works on physical iOS devices or iOS simulators running iOS 13+. It will not work on Android or web platforms in this implementation.
|
||||
</Note>
|
||||
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
## Key Features Implemented
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Apple Developer Configuration" icon="gear">
|
||||
Complete setup of Apple Developer Console including App ID configuration, Service ID creation, and private key generation for secure authentication.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Nhost Provider Integration" icon="link">
|
||||
Seamless integration with Nhost Auth system using Apple's identity tokens and authorization codes for secure user authentication.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Native iOS Integration" icon="mobile">
|
||||
Native Apple Sign In button using expo-apple-authentication with proper iOS styling and user experience guidelines.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Error Handling" icon="triangle-exclamation">
|
||||
Comprehensive error handling for Apple Sign In failures, network issues, and user cancellations with appropriate user feedback.
|
||||
</Accordion>
|
||||
|
||||
</AccordionGroup>
|
||||
116
docs/getting-started/tutorials/svelte/1-introduction.mdx
Normal file
@@ -0,0 +1,116 @@
|
||||
---
|
||||
title: Create Your Nhost Project
|
||||
description: Learn how to create and set up a new Nhost project to get started building your SvelteKit application
|
||||
sidebarTitle: Create Project
|
||||
icon: plus
|
||||
---
|
||||
|
||||
Welcome to the **Full-Stack SvelteKit Development with Nhost** series! In this comprehensive tutorial series, you'll build a complete SvelteKit application with Nhost that demonstrates authentication, database operations, and file management.
|
||||
|
||||
## About This Tutorial Series
|
||||
|
||||
This tutorial series is divided into **5 parts**, each focusing on a specific aspect of building modern web applications with Nhost and SvelteKit. By the end of the series, you'll have built a fully functional application featuring:
|
||||
|
||||
- **User Authentication** - Complete sign up, sign in, and email verification flow
|
||||
- **Todo Management** - Users can create, update, delete, and mark todos as complete
|
||||
- **File Uploads** - Users can upload and manage files with proper permissions
|
||||
- **Protected Routes** - Secure areas that only authenticated users can access
|
||||
|
||||
<Info>
|
||||
This is **Part 1** in the Full-Stack SvelteKit Development with Nhost series. This part sets up the foundation by creating your Nhost project and understanding the series structure.
|
||||
</Info>
|
||||
|
||||
## Full-Stack SvelteKit Development with Nhost
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/svelte/1-introduction">
|
||||
**Current** - Set up your Nhost project
|
||||
</Card>
|
||||
|
||||
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/svelte/2-protected-routes">
|
||||
Route protection basics
|
||||
</Card>
|
||||
|
||||
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/svelte/3-user-authentication">
|
||||
Complete auth flow
|
||||
</Card>
|
||||
|
||||
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/svelte/4-graphql-operations">
|
||||
CRUD operations with GraphQL
|
||||
</Card>
|
||||
|
||||
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/svelte/5-file-uploads">
|
||||
File upload and management
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## What You'll Learn
|
||||
|
||||
Throughout this series, you'll master:
|
||||
|
||||
- Setting up and configuring Nhost projects
|
||||
- Implementing secure authentication flows
|
||||
- Building protected routes with SvelteKit
|
||||
- Performing GraphQL queries and mutations
|
||||
- Managing file uploads and storage
|
||||
- Configuring database permissions and security
|
||||
- Building responsive SvelteKit interfaces
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 20+ installed on your machine
|
||||
- Basic knowledge of Svelte and JavaScript
|
||||
- Understanding of modern web development concepts
|
||||
|
||||
Creating an Nhost project is the first step to building your application with Nhost. Let's get started by setting up your backend infrastructure.
|
||||
|
||||
## Step-by-Step Guide
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
|
||||
### Sign Up or Log in
|
||||
|
||||
If you don't have an Nhost account, sign up at [Nhost](https://app.nhost.io/). If you already have an account, log in.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Create a New Project
|
||||
|
||||
Click on the "Create Project" button on your dashboard or follow the onboarding prompts if you're a new user.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Take note of your project subdomain and region
|
||||
|
||||
Take note of your project subdomain and region. You will need this information to connect your application to the Nhost backend in upcoming tutorials.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
## What's Next?
|
||||
|
||||
With your Nhost project created, you now have access to:
|
||||
|
||||
- [**PostgreSQL Database**](/products/database/overview) - For storing your application data
|
||||
- [**Authentication Service**](/products/auth/overview) - For managing users and sessions
|
||||
- [**GraphQL API**](/products/graphql/overview) - For querying and mutating data
|
||||
- [**File Storage**](/products/storage/overview) - For uploading and managing files
|
||||
- [**Functions**](/products/functions/overview) - For running serverless functions
|
||||
|
||||
In the [next tutorial](/getting-started/tutorials/svelte/2-protected-routes), you'll start building your SvelteKit application and learn how to protect routes based on user authentication status.
|
||||
|
||||
<Tip>
|
||||
Keep your project subdomain and region handy - you'll need them throughout the series to connect your SvelteKit application to the Nhost backend.
|
||||
</Tip>
|
||||
1308
docs/getting-started/tutorials/svelte/2-protected-routes.mdx
Normal file
578
docs/getting-started/tutorials/svelte/3-user-authentication.mdx
Normal file
@@ -0,0 +1,578 @@
|
||||
---
|
||||
title: User Authentication in SvelteKit
|
||||
description: Learn how to implement user authentication in a SvelteKit application using Nhost
|
||||
sidebarTitle: "User Authentication"
|
||||
icon: user
|
||||
---
|
||||
|
||||
This tutorial part builds upon the [Protected Routes part](/getting-started/tutorials/svelte/2-protected-routes) by adding complete email/password authentication with email verification functionality. You'll implement sign up, sign in, email verification, and sign out features to create a full authentication flow.
|
||||
|
||||
<Info>
|
||||
This is **Part 3** in the Full-Stack SvelteKit Development with Nhost series. This part creates a production-ready authentication system with secure email verification and proper error handling.
|
||||
</Info>
|
||||
|
||||
## Full-Stack SvelteKit Development with Nhost
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/svelte/1-introduction">
|
||||
Set up your Nhost project
|
||||
</Card>
|
||||
|
||||
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/svelte/2-protected-routes">
|
||||
Route protection basics
|
||||
</Card>
|
||||
|
||||
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/svelte/3-user-authentication">
|
||||
**Current** - Complete auth flow
|
||||
</Card>
|
||||
|
||||
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/svelte/4-graphql-operations">
|
||||
CRUD operations with GraphQL
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Complete the [Protected Routes part](/getting-started/tutorials/svelte/2-protected-routes) first
|
||||
- The project from the previous part set up and running
|
||||
|
||||
## Step-by-Step Guide
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
|
||||
### Create the Sign In Page
|
||||
|
||||
Build a comprehensive sign-in form with proper error handling and loading states. This page handles user authentication and includes special logic for post-verification sign-in.
|
||||
|
||||
```svelte src/routes/signin/+page.svelte lines
|
||||
<script lang="ts">
|
||||
import type { ErrorResponse } from "@nhost/nhost-js/auth";
|
||||
import type { FetchError } from "@nhost/nhost-js/fetch";
|
||||
import { goto } from "$app/navigation";
|
||||
import { auth, nhost } from "$lib/nhost/auth";
|
||||
|
||||
let email = $state("");
|
||||
let password = $state("");
|
||||
let isLoading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Navigate to profile when authenticated
|
||||
$effect(() => {
|
||||
if ($auth.isAuthenticated) {
|
||||
void goto("/profile");
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
isLoading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
// Use the signIn function from auth context
|
||||
const response = await nhost.auth.signInEmailPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
// If we have a session, sign in was successful
|
||||
if (response.body?.session) {
|
||||
void goto("/profile");
|
||||
} else {
|
||||
error = "Failed to sign in. Please check your credentials.";
|
||||
}
|
||||
} catch (err) {
|
||||
const fetchError = err as FetchError<ErrorResponse>;
|
||||
error = `An error occurred during sign in: ${fetchError.message}`;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<h1>Sign In</h1>
|
||||
|
||||
<form onsubmit={handleSubmit} class="auth-form">
|
||||
<div class="auth-form-field">
|
||||
<label for="email">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:value={email}
|
||||
required
|
||||
class="auth-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="auth-form-field">
|
||||
<label for="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
required
|
||||
class="auth-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="auth-error">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
class="auth-button secondary"
|
||||
>
|
||||
{isLoading ? "Signing In..." : "Sign In"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-links">
|
||||
<p>
|
||||
Don't have an account? <a href="/signup">Sign Up</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Create the Sign Up Page
|
||||
|
||||
Implement user registration with email verification flow. This page collects user information, creates accounts, and guides users through the email verification process.
|
||||
|
||||
```svelte src/routes/signup/+page.svelte lines
|
||||
<script lang="ts">
|
||||
import type { ErrorResponse } from "@nhost/nhost-js/auth";
|
||||
import type { FetchError } from "@nhost/nhost-js/fetch";
|
||||
import { goto } from "$app/navigation";
|
||||
import { auth, nhost } from "$lib/nhost/auth";
|
||||
|
||||
let email = $state("");
|
||||
let password = $state("");
|
||||
let displayName = $state("");
|
||||
let isLoading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let success = $state(false);
|
||||
|
||||
// If already authenticated, redirect to profile
|
||||
$effect(() => {
|
||||
if ($auth.isAuthenticated) {
|
||||
void goto("/profile");
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
isLoading = true;
|
||||
error = null;
|
||||
success = false;
|
||||
|
||||
try {
|
||||
const response = await nhost.auth.signUpEmailPassword({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
displayName,
|
||||
// Set the redirect URL for email verification
|
||||
redirectTo: `${window.location.origin}/verify`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.body?.session) {
|
||||
// Successfully signed up and automatically signed in
|
||||
void goto("/profile");
|
||||
} else {
|
||||
// Verification email sent
|
||||
success = true;
|
||||
}
|
||||
} catch (err) {
|
||||
const fetchError = err as FetchError<ErrorResponse>;
|
||||
error = `An error occurred during sign up: ${fetchError.message}`;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if success}
|
||||
<div>
|
||||
<h1>Check Your Email</h1>
|
||||
<div class="success-message">
|
||||
<p>
|
||||
We've sent a verification link to <strong>{email}</strong>
|
||||
</p>
|
||||
<p>
|
||||
Please check your email and click the verification link to activate your account.
|
||||
</p>
|
||||
</div>
|
||||
<p>
|
||||
<a href="/signin">Back to Sign In</a>
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
<h1>Sign Up</h1>
|
||||
|
||||
<form onsubmit={handleSubmit} class="auth-form">
|
||||
<div class="auth-form-field">
|
||||
<label for="displayName">Display Name</label>
|
||||
<input
|
||||
id="displayName"
|
||||
type="text"
|
||||
bind:value={displayName}
|
||||
required
|
||||
class="auth-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="auth-form-field">
|
||||
<label for="email">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:value={email}
|
||||
required
|
||||
class="auth-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="auth-form-field">
|
||||
<label for="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
required
|
||||
minlength="8"
|
||||
class="auth-input"
|
||||
/>
|
||||
<small class="help-text">Minimum 8 characters</small>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="auth-error">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
class="auth-button primary"
|
||||
>
|
||||
{isLoading ? "Creating Account..." : "Sign Up"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-links">
|
||||
<p>
|
||||
Already have an account? <a href="/signin">Sign In</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Create the Email Verification Page
|
||||
|
||||
Build a dedicated verification page that processes email verification tokens. This page handles the verification flow when users click the email verification link.
|
||||
|
||||
```svelte src/routes/verify/+page.svelte lines
|
||||
<script lang="ts">
|
||||
import type { ErrorResponse } from "@nhost/nhost-js/auth";
|
||||
import type { FetchError } from "@nhost/nhost-js/fetch";
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
import { nhost } from "$lib/nhost/auth";
|
||||
|
||||
let status: "verifying" | "success" | "error" = "verifying";
|
||||
let error = "";
|
||||
let urlParams: Record<string, string> = {};
|
||||
|
||||
onMount(() => {
|
||||
// Extract the refresh token from the URL
|
||||
const params = new URLSearchParams($page.url.search);
|
||||
const refreshToken = params.get("refreshToken");
|
||||
|
||||
if (!refreshToken) {
|
||||
// Collect all URL parameters to display for debugging
|
||||
const allParams: Record<string, string> = {};
|
||||
params.forEach((value, key) => {
|
||||
allParams[key] = value;
|
||||
});
|
||||
urlParams = allParams;
|
||||
|
||||
status = "error";
|
||||
error = "No refresh token found in URL";
|
||||
return;
|
||||
}
|
||||
|
||||
// Flag to handle component unmounting during async operations
|
||||
let isMounted = true;
|
||||
|
||||
async function processToken() {
|
||||
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> = {};
|
||||
params.forEach((value, key) => {
|
||||
allParams[key] = value;
|
||||
});
|
||||
urlParams = allParams;
|
||||
|
||||
status = "error";
|
||||
error = "No refresh token found in URL";
|
||||
return;
|
||||
}
|
||||
|
||||
// Process the token
|
||||
await nhost.auth.refreshToken({ refreshToken });
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
status = "success";
|
||||
|
||||
// Wait to show success message briefly, then redirect
|
||||
setTimeout(() => {
|
||||
if (isMounted) void goto("/profile");
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
const fetchError = err as FetchError<ErrorResponse>;
|
||||
if (!isMounted) return;
|
||||
|
||||
status = "error";
|
||||
error = `An error occurred during verification: ${fetchError.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
void processToken();
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<h1>Email Verification</h1>
|
||||
|
||||
<div class="page-center">
|
||||
{#if status === "verifying"}
|
||||
<div>
|
||||
<p class="margin-bottom">Verifying your email...</p>
|
||||
<div class="spinner-verify" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if status === "success"}
|
||||
<div>
|
||||
<p class="verification-status">
|
||||
✓ Successfully verified!
|
||||
</p>
|
||||
<p>You'll be redirected to your profile page shortly...</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if status === "error"}
|
||||
<div>
|
||||
<p class="verification-status error">
|
||||
Verification failed
|
||||
</p>
|
||||
<p class="margin-bottom">{error}</p>
|
||||
|
||||
{#if Object.keys(urlParams).length > 0}
|
||||
<div class="debug-panel">
|
||||
<p class="debug-title">
|
||||
URL Parameters:
|
||||
</p>
|
||||
{#each Object.entries(urlParams) as [key, value] (key)}
|
||||
<div class="debug-item">
|
||||
<span class="debug-key">
|
||||
{key}:
|
||||
</span>
|
||||
<span class="debug-value">{value}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => goto("/signin")}
|
||||
class="auth-button secondary"
|
||||
>
|
||||
Back to Sign In
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.spinner-verify {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 2px solid transparent;
|
||||
border-top: 2px solid var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
<Warning>
|
||||
**Important Configuration Required:** Before testing email verification, you must configure your Nhost project's authentication settings:
|
||||
|
||||
1. Go to your Nhost project dashboard
|
||||
2. Navigate to **Settings → Authentication**
|
||||
3. Add your local development URL (e.g., `http://localhost:5173`) to the **Allowed Redirect URLs** field
|
||||
4. Ensure your production domain is also added when deploying
|
||||
|
||||
Without this configuration, you'll receive a `redirectTo not allowed` error when users attempt to sign up or verify their email addresses.
|
||||
</Warning>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Update the Layout Component to Include New Routes
|
||||
|
||||
Configure your application's routing structure to include the new authentication pages. In SvelteKit, routes are automatically created based on the file structure, so you'll update the layout component to handle authentication state properly.
|
||||
|
||||
```svelte src/routes/+layout.svelte lines highlight={20-27,40-45,47-52}
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
import { auth, initializeAuth, nhost } from "$lib/nhost/auth";
|
||||
import "../app.css";
|
||||
|
||||
let { children }: { children?: import("svelte").Snippet } = $props();
|
||||
|
||||
// Initialize auth when component mounts
|
||||
onMount(() => {
|
||||
return initializeAuth();
|
||||
});
|
||||
|
||||
// Helper function to determine if a link is active
|
||||
function isActive(path: string): string {
|
||||
return $page.url.pathname === path ? "nav-link active" : "nav-link";
|
||||
}
|
||||
|
||||
async function handleSignOut() {
|
||||
if ($auth.session) {
|
||||
await nhost.auth.signOut({
|
||||
refreshToken: $auth.session.refreshToken,
|
||||
});
|
||||
void goto("/");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div id="root">
|
||||
<nav class="navigation">
|
||||
<div class="nav-container">
|
||||
<a href="/" class="nav-logo">Nhost SvelteKit Demo</a>
|
||||
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link">Home</a>
|
||||
|
||||
{#if $auth.isAuthenticated}
|
||||
<a href="/profile" class={isActive('/profile')}>Profile</a>
|
||||
<button
|
||||
onclick={handleSignOut}
|
||||
class="nav-link nav-button"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
{:else}
|
||||
<a href="/signin" class="nav-link {isActive('/signin')}">
|
||||
Sign In
|
||||
</a>
|
||||
<a href="/signup" class="nav-link {isActive('/signup')}">
|
||||
Sign Up
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="app-content">
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Run and Test the Application
|
||||
|
||||
Start your development server and test the complete authentication flow to ensure everything works properly.
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Things to try out:
|
||||
|
||||
1. Try signing up with a new email address. Check your email for the verification link and click it. See how you are sent to the verification page and then redirected to your profile.
|
||||
2. Try signing out and then signing back in with the same credentials.
|
||||
3. Notice how navigation links change based on authentication state showing "Sign In" and "Sign Up" when logged out, and "Profile" and "Sign Out" when logged in.
|
||||
4. Check how the homepage also reflects the authentication state with appropriate messages.
|
||||
5. Open multiple tabs and test signing out from one tab to see how other tabs respond. Now sign back in and see the changes propagate across tabs.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Key Features Demonstrated
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Complete Registration Flow" icon="user-plus">
|
||||
Full email/password registration with proper form validation and user feedback using SvelteKit's reactive state management.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Email Verification" icon="envelope-circle-check">
|
||||
Custom `/verify` route that securely processes email verification tokens using SvelteKit's page stores and navigation utilities.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Error Handling" icon="triangle-exclamation">
|
||||
Comprehensive error handling for unverified emails, failed authentication, and network issues with proper TypeScript error typing.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Visual Feedback" icon="eye">
|
||||
Loading states, success messages, and clear error displays throughout the authentication flow using Svelte's reactive declarations.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Session Management" icon="clock">
|
||||
Complete sign out functionality and proper session state management across the application using Svelte stores and cross-tab synchronization.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
763
docs/getting-started/tutorials/svelte/4-graphql-operations.mdx
Normal file
@@ -0,0 +1,763 @@
|
||||
---
|
||||
title: GraphQL Operations in SvelteKit
|
||||
description: Learn how to perform GraphQL operations and manage database permissions while building a complete todos application with Nhost and SvelteKit
|
||||
sidebarTitle: "GraphQL Operations"
|
||||
icon: code
|
||||
---
|
||||
|
||||
This part builds upon the previous parts by demonstrating how to perform GraphQL operations with proper database permissions. You'll learn how to design database tables, configure user permissions, and implement complete CRUD operations through GraphQL queries and mutations in a real todos application.
|
||||
|
||||
<Info>
|
||||
This is **Part 4** in the Full-Stack SvelteKit Development with Nhost series. This part focuses on GraphQL operations, database management, and permission-based data access control in a production application.
|
||||
</Info>
|
||||
|
||||
## Full-Stack SvelteKit Development with Nhost
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/svelte/1-introduction">
|
||||
Set up your Nhost project
|
||||
</Card>
|
||||
|
||||
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/svelte/2-protected-routes">
|
||||
Route protection basics
|
||||
</Card>
|
||||
|
||||
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/svelte/3-user-authentication">
|
||||
Complete auth flow
|
||||
</Card>
|
||||
|
||||
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/svelte/4-graphql-operations">
|
||||
**Current** - CRUD operations with GraphQL
|
||||
</Card>
|
||||
|
||||
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/svelte/5-file-uploads">
|
||||
File upload and management
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Complete the [User Authentication part](/getting-started/tutorials/svelte/3-user-authentication) first
|
||||
- The project from the previous part set up and running
|
||||
|
||||
## What You'll Build
|
||||
|
||||
By the end of this part, you'll have:
|
||||
- **GraphQL queries and mutations** for complete CRUD operations
|
||||
- **Database schema** with proper relationships and constraints
|
||||
- **User permissions** for secure data access control
|
||||
- **SvelteKit components** that interact with GraphQL endpoint
|
||||
|
||||
## Step-by-Step Guide
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
|
||||
### Create the To-Dos Table
|
||||
|
||||
First, we'll perform the database changes to set up the todos table with proper schema and relationships to users.
|
||||
|
||||
In your Nhost project dashboard:
|
||||
1. Navigate to **Database**
|
||||
2. Click on the SQL Editor
|
||||
|
||||
Enter the following SQL:
|
||||
|
||||
<Tabs>
|
||||
|
||||
<Tab title="SQL">
|
||||
|
||||
```sql
|
||||
CREATE TABLE public.todos (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
created_at timestamptz DEFAULT now() NOT NULL,
|
||||
updated_at timestamptz DEFAULT now() NOT NULL,
|
||||
title text NOT NULL,
|
||||
details text,
|
||||
completed bool DEFAULT false NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (user_id) REFERENCES auth.users (id) ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
|
||||
CREATE TRIGGER update_todos_updated_at
|
||||
BEFORE UPDATE ON public.todos
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
```
|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab title="UI">
|
||||

|
||||
</Tab>
|
||||
|
||||
|
||||
</Tabs>
|
||||
|
||||
<Warning>
|
||||
Please make sure to enable **Track this** so that the new table todos is available through the auto-generated APIs
|
||||
</Warning>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Set Up Permissions
|
||||
|
||||
It’s now time to set permission rules for the table you just created. With the table `todos` selected, click on **…**, followed by **Edit Permissions**.
|
||||
You will set permissions for the **user** role and actions **insert**, **select**, **update**, and **delete**.
|
||||
|
||||
<Tabs>
|
||||
|
||||
<Tab title="Insert">
|
||||
|
||||
When inserting permissions we are only allowing users to set the `title`, `details`, and `completed` columns as the rest of the columns are set automatically by the backend. The `user_id` column is configured as a preset to the currently authenticated user's ID using the `X-Hasura-User-Id` session variable. This ensures that each todo is associated with the user who created it.
|
||||
|
||||
|
||||

|
||||
</Tab>
|
||||
|
||||
<Tab title="Select">
|
||||
|
||||
For selecting (reading) todos, we are allowing to read all columns but only for rows where the `user_id` matches the authenticated user's ID. This ensures that users can only see their own todos.
|
||||
|
||||

|
||||
</Tab>
|
||||
|
||||
<Tab title="Update">
|
||||
|
||||
When updating todos, we are allowing users to modify the `title`, `details`, and `completed` columns but only for rows where the `user_id` matches their own ID. This prevents users from modifying todos that do not belong to them.
|
||||
|
||||

|
||||
</Tab>
|
||||
|
||||
<Tab title="Delete">
|
||||
|
||||
For deleting todos, we are allowing users to delete rows only where the `user_id` matches their own ID. This ensures that users cannot delete todos that belong to other users.
|
||||
|
||||

|
||||
</Tab>
|
||||
|
||||
</Tabs>
|
||||
|
||||
</Step>
|
||||
|
||||
|
||||
<Step>
|
||||
|
||||
### Create the Todos Page Component
|
||||
|
||||
Now let's implement the SvelteKit page component that uses the database we just configured.
|
||||
|
||||
```svelte src/routes/todos/+page.svelte lines
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { auth } from "$lib/nhost/auth";
|
||||
|
||||
// The interfaces below define the structure of our data
|
||||
// They are not strictly necessary but help with type safety
|
||||
|
||||
// Represents a single todo item
|
||||
interface Todo {
|
||||
id: string;
|
||||
title: string;
|
||||
details: string | null;
|
||||
completed: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
// This matches the GraphQL response structure for fetching todos
|
||||
// Can be used as a generic type on the request method
|
||||
interface GetTodos {
|
||||
todos: Todo[];
|
||||
}
|
||||
|
||||
// This matches the GraphQL response structure for inserting a todo
|
||||
// Can be used as a generic type on the request method
|
||||
interface InsertTodo {
|
||||
insert_todos_one: Todo | null;
|
||||
}
|
||||
|
||||
// This matches the GraphQL response structure for updating a todo
|
||||
// Can be used as a generic type on the request method
|
||||
interface UpdateTodo {
|
||||
update_todos_by_pk: Todo | null;
|
||||
}
|
||||
|
||||
let todos = $state<Todo[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let newTodoTitle = $state("");
|
||||
let newTodoDetails = $state("");
|
||||
let editingTodo = $state<Todo | null>(null);
|
||||
let showAddForm = $state(false);
|
||||
let expandedTodos = $state<Set<string>>(new Set());
|
||||
|
||||
// Redirect if not authenticated
|
||||
$effect(() => {
|
||||
if (!$auth.isLoading && !$auth.isAuthenticated) {
|
||||
void goto("/signin");
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchTodos() {
|
||||
try {
|
||||
loading = true;
|
||||
// Make GraphQL request to fetch todos using Nhost client
|
||||
// The query automatically filters by user_id due to Hasura permissions
|
||||
const response = await $auth.nhost.graphql.request<GetTodos>({
|
||||
query: `
|
||||
query GetTodos {
|
||||
todos(order_by: { created_at: desc }) {
|
||||
id
|
||||
title
|
||||
details
|
||||
completed
|
||||
created_at
|
||||
updated_at
|
||||
user_id
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
// Check for GraphQL errors in the response body
|
||||
if (response.body.errors) {
|
||||
throw new Error(
|
||||
response.body.errors[0]?.message || "Failed to fetch todos",
|
||||
);
|
||||
}
|
||||
|
||||
// Extract todos from the GraphQL response data
|
||||
todos = response.body?.data?.todos || [];
|
||||
error = null;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : "Failed to fetch todos";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addTodo(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (!newTodoTitle.trim()) return;
|
||||
|
||||
try {
|
||||
// Execute GraphQL mutation to insert a new todo
|
||||
// user_id is automatically set by Hasura based on JWT token
|
||||
const response = await $auth.nhost.graphql.request<InsertTodo>({
|
||||
query: `
|
||||
mutation InsertTodo($title: String!, $details: String) {
|
||||
insert_todos_one(object: { title: $title, details: $details }) {
|
||||
id
|
||||
title
|
||||
details
|
||||
completed
|
||||
created_at
|
||||
updated_at
|
||||
user_id
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
title: newTodoTitle.trim(),
|
||||
details: newTodoDetails.trim() || null,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.body.errors) {
|
||||
throw new Error(response.body.errors[0]?.message || "Failed to add todo");
|
||||
}
|
||||
|
||||
if (!response.body?.data?.insert_todos_one) {
|
||||
throw new Error("Failed to add todo");
|
||||
}
|
||||
todos = [response.body?.data?.insert_todos_one, ...todos];
|
||||
newTodoTitle = "";
|
||||
newTodoDetails = "";
|
||||
showAddForm = false;
|
||||
error = null;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : "Failed to add todo";
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTodo(
|
||||
id: string,
|
||||
updates: Partial<Pick<Todo, "title" | "details" | "completed">>,
|
||||
) {
|
||||
try {
|
||||
// Execute GraphQL mutation to update an existing todo by primary key
|
||||
// Hasura permissions ensure users can only update their own todos
|
||||
const response = await $auth.nhost.graphql.request<UpdateTodo>({
|
||||
query: `
|
||||
mutation UpdateTodo($id: uuid!, $updates: todos_set_input!) {
|
||||
update_todos_by_pk(pk_columns: { id: $id }, _set: $updates) {
|
||||
id
|
||||
title
|
||||
details
|
||||
completed
|
||||
created_at
|
||||
updated_at
|
||||
user_id
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
id,
|
||||
updates,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.body.errors) {
|
||||
throw new Error(
|
||||
response.body.errors[0]?.message || "Failed to update todo",
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.body?.data?.update_todos_by_pk) {
|
||||
throw new Error("Failed to update todo");
|
||||
}
|
||||
|
||||
const updatedTodo = response.body?.data?.update_todos_by_pk;
|
||||
if (updatedTodo) {
|
||||
todos = todos.map((todo) => (todo.id === id ? updatedTodo : todo));
|
||||
}
|
||||
editingTodo = null;
|
||||
error = null;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : "Failed to update todo";
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTodo(id: string) {
|
||||
if (!confirm("Are you sure you want to delete this todo?")) return;
|
||||
|
||||
try {
|
||||
// Execute GraphQL mutation to delete a todo by primary key
|
||||
// Hasura permissions ensure users can only delete their own todos
|
||||
const response = await $auth.nhost.graphql.request({
|
||||
query: `
|
||||
mutation DeleteTodo($id: uuid!) {
|
||||
delete_todos_by_pk(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.body.errors) {
|
||||
throw new Error(
|
||||
response.body.errors[0]?.message || "Failed to delete todo",
|
||||
);
|
||||
}
|
||||
|
||||
todos = todos.filter((todo) => todo.id !== id);
|
||||
error = null;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : "Failed to delete todo";
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleComplete(todo: Todo) {
|
||||
await updateTodo(todo.id, { completed: !todo.completed });
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editingTodo) return;
|
||||
await updateTodo(editingTodo.id, {
|
||||
title: editingTodo.title,
|
||||
details: editingTodo.details,
|
||||
});
|
||||
}
|
||||
|
||||
function toggleTodoExpansion(todoId: string) {
|
||||
const newExpanded = new Set(expandedTodos);
|
||||
if (newExpanded.has(todoId)) {
|
||||
newExpanded.delete(todoId);
|
||||
} else {
|
||||
newExpanded.add(todoId);
|
||||
}
|
||||
expandedTodos = newExpanded;
|
||||
}
|
||||
|
||||
// Fetch todos when user session is available
|
||||
// The session contains the JWT token needed for GraphQL authentication
|
||||
$effect(() => {
|
||||
if ($auth.session) {
|
||||
fetchTodos();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !$auth.session}
|
||||
<div class="auth-message">
|
||||
<p>Please sign in to view your todos.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="container">
|
||||
<header class="page-header">
|
||||
<h1 class="page-title">
|
||||
My Todos
|
||||
{#if !showAddForm}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showAddForm = true)}
|
||||
class="add-todo-btn"
|
||||
title="Add a new todo"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
{/if}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<div class="error-message">
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showAddForm}
|
||||
<div class="todo-form-card">
|
||||
<form onsubmit={addTodo} class="todo-form">
|
||||
<h2 class="form-title">Add New Todo</h2>
|
||||
<div class="form-fields">
|
||||
<div class="field-group">
|
||||
<label for="title">Title *</label>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
bind:value={newTodoTitle}
|
||||
placeholder="What needs to be done?"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label for="details">Details</label>
|
||||
<textarea
|
||||
id="details"
|
||||
bind:value={newTodoDetails}
|
||||
placeholder="Add some details (optional)..."
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Add Todo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
showAddForm = false;
|
||||
newTodoTitle = "";
|
||||
newTodoDetails = "";
|
||||
}}
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !showAddForm}
|
||||
{#if loading}
|
||||
<div class="loading-container">
|
||||
<div class="loading-content">
|
||||
<div class="spinner"></div>
|
||||
<span class="loading-text">Loading todos...</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="todos-list">
|
||||
{#if todos.length === 0}
|
||||
<div class="empty-state">
|
||||
<svg
|
||||
class="empty-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="empty-title">No todos yet</h3>
|
||||
<p class="empty-description">
|
||||
Create your first todo to get started!
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each todos as todo (todo.id)}
|
||||
<div class="todo-card {todo.completed ? 'completed' : ''}">
|
||||
{#if editingTodo?.id === todo.id}
|
||||
<div class="todo-edit">
|
||||
<div class="edit-fields">
|
||||
<div class="field-group">
|
||||
<label for="edit-title">Title</label>
|
||||
<input
|
||||
id="edit-title"
|
||||
type="text"
|
||||
bind:value={editingTodo.title}
|
||||
/>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label for="edit-details">Details</label>
|
||||
<textarea
|
||||
id="edit-details"
|
||||
bind:value={editingTodo.details}
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="edit-actions">
|
||||
<button
|
||||
type="button"
|
||||
onclick={saveEdit}
|
||||
class="btn btn-primary"
|
||||
>
|
||||
✓ Save Changes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (editingTodo = null)}
|
||||
class="btn btn-cancel"
|
||||
>
|
||||
✕ Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="todo-content">
|
||||
<div class="todo-header">
|
||||
<button
|
||||
type="button"
|
||||
class="todo-title-btn {todo.completed ? 'completed' : ''}"
|
||||
onclick={() => toggleTodoExpansion(todo.id)}
|
||||
>
|
||||
{todo.title}
|
||||
</button>
|
||||
<div class="todo-actions">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleComplete(todo)}
|
||||
class="action-btn action-btn-complete"
|
||||
title={todo.completed
|
||||
? "Mark as incomplete"
|
||||
: "Mark as complete"}
|
||||
>
|
||||
{todo.completed ? "↶" : "✓"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (editingTodo = todo)}
|
||||
class="action-btn action-btn-edit"
|
||||
title="Edit todo"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => deleteTodo(todo.id)}
|
||||
class="action-btn action-btn-delete"
|
||||
title="Delete todo"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if expandedTodos.has(todo.id)}
|
||||
<div class="todo-details">
|
||||
{#if todo.details}
|
||||
<div class="todo-description {todo.completed ? 'completed' : ''}">
|
||||
<p>{todo.details}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="todo-meta">
|
||||
<div class="meta-dates">
|
||||
<span class="meta-item">
|
||||
Created: {new Date(todo.created_at).toLocaleString()}
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
Updated: {new Date(todo.updated_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
{#if todo.completed}
|
||||
<div class="completion-badge">
|
||||
<svg
|
||||
class="completion-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Completed</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Update Navigation Links
|
||||
|
||||
Add a link to the todos page in the navigation layout. Update your `src/routes/+layout.svelte` file to include the todos link:
|
||||
|
||||
```svelte src/routes/+layout.svelte lines highlight={39}
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
import { auth, initializeAuth, nhost } from "$lib/nhost/auth";
|
||||
import "../app.css";
|
||||
|
||||
let { children }: { children?: import("svelte").Snippet } = $props();
|
||||
|
||||
// Initialize auth when component mounts
|
||||
onMount(() => {
|
||||
return initializeAuth();
|
||||
});
|
||||
|
||||
// Helper function to determine if a link is active
|
||||
function isActive(path: string): string {
|
||||
return $page.url.pathname === path ? "nav-link active" : "nav-link";
|
||||
}
|
||||
|
||||
async function handleSignOut() {
|
||||
if ($auth.session) {
|
||||
await nhost.auth.signOut({
|
||||
refreshToken: $auth.session.refreshToken,
|
||||
});
|
||||
void goto("/");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div id="root">
|
||||
<nav class="navigation">
|
||||
<div class="nav-container">
|
||||
<a href="/" class="nav-logo">Nhost SvelteKit Demo</a>
|
||||
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link">Home</a>
|
||||
|
||||
{#if $auth.isAuthenticated}
|
||||
<a href="/todos" class={isActive('/todos')}>Todos</a>
|
||||
<a href="/profile" class={isActive('/profile')}>Profile</a>
|
||||
<button
|
||||
onclick={handleSignOut}
|
||||
class="nav-link nav-button"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
{:else}
|
||||
<a href="/signin" class="nav-link {isActive('/signin')}">
|
||||
Sign In
|
||||
</a>
|
||||
<a href="/signup" class="nav-link {isActive('/signup')}">
|
||||
Sign Up
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="app-content">
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Test Your Complete Application
|
||||
|
||||
Run your SvelteKit application and test all the functionality:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Things to try out:
|
||||
|
||||
1. Try signing in and out and see how the Todos page is only available when authenticated
|
||||
2. Create, view, edit, complete, and delete todos. See how the UI updates accordingly
|
||||
3. Open the application in another browser or incognito window, sign in with a different account and verify that you cannot see or modify todos from the first account
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Key Features Implemented
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Database Schema" icon="database">
|
||||
Properly designed todos table with constraints, indexes, and automatic timestamp updates for optimal performance.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="GraphQL API" icon="webhook">
|
||||
Auto-generated GraphQL API with queries and mutations for full CRUD operations on todos.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Row-Level Security" icon="shield-check">
|
||||
Comprehensive permissions ensuring users can only access their own todos through all GraphQL operations.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="CRUD Operations" icon="arrows-rotate">
|
||||
Complete Create, Read, Update, Delete functionality with proper error handling and user feedback.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Rich Interface" icon="sparkles">
|
||||
Expandable todo items, inline editing, completion status, and detailed timestamps using SvelteKit's reactive UI patterns.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
642
docs/getting-started/tutorials/svelte/5-file-uploads.mdx
Normal file
@@ -0,0 +1,642 @@
|
||||
---
|
||||
title: File Uploads in SvelteKit
|
||||
description: Learn how to implement file upload functionality with storage buckets and permissions while building a complete file management system with Nhost and SvelteKit
|
||||
sidebarTitle: "File Uploads"
|
||||
icon: upload
|
||||
---
|
||||
|
||||
This part builds upon the previous GraphQL operations part by demonstrating how to implement file upload functionality with proper storage permissions. You'll learn how to create storage buckets, configure upload permissions, and implement complete file management operations in a SvelteKit application.
|
||||
|
||||
<Info>
|
||||
This is **Part 5** in the Full-Stack SvelteKit Development with Nhost series. This part focuses on file storage, upload operations, and permission-based file access control in a production application.
|
||||
</Info>
|
||||
|
||||
## Full-Stack SvelteKit Development with Nhost
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/svelte/1-introduction">
|
||||
Set up your Nhost project
|
||||
</Card>
|
||||
|
||||
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/svelte/2-protected-routes">
|
||||
Route protection basics
|
||||
</Card>
|
||||
|
||||
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/svelte/3-user-authentication">
|
||||
Complete auth flow
|
||||
</Card>
|
||||
|
||||
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/svelte/4-graphql-operations">
|
||||
CRUD operations with GraphQL
|
||||
</Card>
|
||||
|
||||
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/svelte/5-file-uploads">
|
||||
**Current** - File upload and management
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Complete the [GraphQL Operations part](/getting-started/tutorials/svelte/4-graphql-operations) first
|
||||
- The project from the previous part set up and running
|
||||
|
||||
## What You'll Build
|
||||
|
||||
By the end of this part, you'll have:
|
||||
- A **personal bucket** so users can upload their own private files
|
||||
- **File upload functionality**
|
||||
- **File management interface** for viewing and deleting files
|
||||
- **Security permissions** ensuring users can only access their own files
|
||||
|
||||
## Step-by-Step Guide
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
|
||||
### Create a Personal Storage Bucket
|
||||
|
||||
First, we'll create a storage bucket where users can upload their personal files.
|
||||
|
||||
In your Nhost project dashboard:
|
||||
1. Navigate to **Database**
|
||||
2. Change to **schema.storage**, then buckets
|
||||
3. Now click on `+ Insert` on the top right corner.
|
||||
4. As id set `personal`, leave the rest of the fields blank and click on Insert at the bottom
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Configure Storage Permissions
|
||||
|
||||
Now we need to set up permissions for the storage bucket to ensure the `user` role can only upload, view, and delete their own files.
|
||||
|
||||
<Tabs>
|
||||
|
||||
<Tab title="Upload">
|
||||
|
||||
To upload files we need to grant permissions to insert on the table `storage.files`. Because we want to allow uploading files only to the `personal` bucket we will be using the `bucket_id eq personal` as a custom check. In addition, we are configuring a preset `uploaded_by_user_id = X-Hasura-User-id`, this will automatically extract the user_id from the session and set the column accordingly. Then we can use this in other permissions to allow downloading files and deleting them.
|
||||
|
||||

|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab title="Download">
|
||||
|
||||
To download files users need to be able to query those files. To make sure users can only download files they uploaded we will be leveraging the column `uploaded_by_user_id` column from before and the `bucket_id``.
|
||||
|
||||

|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab title="Delete">
|
||||
|
||||
Similarly to downloading files, to delete files users need to be able to delete rows from the `storage.files` table. Again we will use the `uploaded_by_user_id` column and the `bucket_id` to make sure users can only delete their own files.
|
||||
|
||||

|
||||
|
||||
</Tab>
|
||||
|
||||
</Tabs>
|
||||
|
||||
<Info>
|
||||
You can read more about storage permissions [here](/products/storage/overview#permissions)
|
||||
</Info>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Create the File Upload Component
|
||||
|
||||
Now let's implement the SvelteKit page component for file upload functionality.
|
||||
|
||||
```svelte src/routes/files/+page.svelte lines
|
||||
<script lang="ts">
|
||||
import type { FileMetadata } from "@nhost/nhost-js/storage";
|
||||
import { goto } from "$app/navigation";
|
||||
import { auth } from "$lib/nhost/auth";
|
||||
|
||||
interface DeleteStatus {
|
||||
message: string;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
interface GraphqlGetFilesResponse {
|
||||
files: FileMetadata[];
|
||||
}
|
||||
|
||||
let fileInputRef = $state<HTMLInputElement>();
|
||||
let selectedFile = $state<File | null>(null);
|
||||
let uploading = $state(false);
|
||||
let uploadResult = $state<FileMetadata | null>(null);
|
||||
let isFetching = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let files = $state<FileMetadata[]>([]);
|
||||
let viewingFile = $state<string | null>(null);
|
||||
let deleting = $state<string | null>(null);
|
||||
let deleteStatus = $state<DeleteStatus | null>(null);
|
||||
|
||||
// Redirect if not authenticated
|
||||
$effect(() => {
|
||||
if (!$auth.isLoading && !$auth.isAuthenticated) {
|
||||
void goto("/signin");
|
||||
}
|
||||
});
|
||||
|
||||
// Format file size in a readable way
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
|
||||
const sizes: string[] = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
const i: number = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
|
||||
return `${parseFloat((bytes / 1024 ** i).toFixed(2))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
async function fetchFiles() {
|
||||
isFetching = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
// Use GraphQL to fetch files from the storage system
|
||||
// Files are automatically filtered by user permissions
|
||||
const response = await $auth.nhost.graphql.request<GraphqlGetFilesResponse>(
|
||||
{
|
||||
query: `query GetFiles {
|
||||
files {
|
||||
id
|
||||
name
|
||||
size
|
||||
mimeType
|
||||
bucketId
|
||||
uploadedByUserId
|
||||
}
|
||||
}`,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.body.errors) {
|
||||
throw new Error(
|
||||
response.body.errors[0]?.message || "Failed to fetch files",
|
||||
);
|
||||
}
|
||||
|
||||
files = response.body.data?.files || [];
|
||||
} catch (err) {
|
||||
console.error("Error fetching files:", err);
|
||||
error = "Failed to load files. Please try refreshing the page.";
|
||||
} finally {
|
||||
isFetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch files when user session is available
|
||||
$effect(() => {
|
||||
if ($auth.session) {
|
||||
fetchFiles();
|
||||
}
|
||||
});
|
||||
|
||||
function handleFileChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.files && target.files.length > 0) {
|
||||
const file = target.files[0];
|
||||
if (file) {
|
||||
selectedFile = file;
|
||||
error = null;
|
||||
uploadResult = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
if (!selectedFile) {
|
||||
error = "Please select a file to upload";
|
||||
return;
|
||||
}
|
||||
|
||||
uploading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
// Upload file to the personal bucket
|
||||
// The uploadedByUserId is automatically set by the storage permissions
|
||||
const response = await $auth.nhost.storage.uploadFiles({
|
||||
"bucket-id": "personal",
|
||||
"file[]": [selectedFile],
|
||||
});
|
||||
|
||||
const uploadedFile = response.body.processedFiles?.[0];
|
||||
if (uploadedFile === undefined) {
|
||||
throw new Error("Failed to upload file");
|
||||
}
|
||||
uploadResult = uploadedFile;
|
||||
|
||||
// Clear the form
|
||||
selectedFile = null;
|
||||
if (fileInputRef) {
|
||||
fileInputRef.value = "";
|
||||
}
|
||||
|
||||
// Update the files list
|
||||
files = [uploadedFile, ...files];
|
||||
|
||||
await fetchFiles();
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
uploadResult = null;
|
||||
}, 3000);
|
||||
} catch (err: unknown) {
|
||||
const message = (err as Error).message || "An unknown error occurred";
|
||||
error = `Failed to upload file: ${message}`;
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleViewFile(
|
||||
fileId: string,
|
||||
fileName: string,
|
||||
mimeType: string,
|
||||
) {
|
||||
viewingFile = fileId;
|
||||
|
||||
try {
|
||||
// Get the file from storage
|
||||
const response = await $auth.nhost.storage.getFile(fileId);
|
||||
|
||||
const url = URL.createObjectURL(response.body);
|
||||
|
||||
// Handle different file types appropriately
|
||||
if (
|
||||
mimeType.startsWith("image/") ||
|
||||
mimeType === "application/pdf" ||
|
||||
mimeType.startsWith("text/") ||
|
||||
mimeType.startsWith("video/") ||
|
||||
mimeType.startsWith("audio/")
|
||||
) {
|
||||
// Open viewable files in new tab
|
||||
window.open(url, "_blank");
|
||||
} else {
|
||||
// Download other file types
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// Show download confirmation
|
||||
const newWindow = window.open("", "_blank", "width=400,height=200");
|
||||
if (newWindow) {
|
||||
newWindow.document.documentElement.innerHTML = `
|
||||
<head>
|
||||
<title>File Download</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; padding: 20px; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h3>Downloading: ${fileName}</h3>
|
||||
<p>Your download has started. You can close this window.</p>
|
||||
</body>
|
||||
`;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const message = (err as Error).message || "An unknown error occurred";
|
||||
error = `Failed to view file: ${message}`;
|
||||
console.error("Error viewing file:", err);
|
||||
} finally {
|
||||
viewingFile = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteFile(fileId: string) {
|
||||
if (!fileId || deleting) return;
|
||||
|
||||
deleting = fileId;
|
||||
error = null;
|
||||
deleteStatus = null;
|
||||
|
||||
const fileToDelete = files.find((file) => file.id === fileId);
|
||||
const fileName = fileToDelete?.name || "File";
|
||||
|
||||
try {
|
||||
// Delete file from storage
|
||||
// Permissions ensure users can only delete their own files
|
||||
await $auth.nhost.storage.deleteFile(fileId);
|
||||
|
||||
deleteStatus = {
|
||||
message: `${fileName} deleted successfully`,
|
||||
isError: false,
|
||||
};
|
||||
|
||||
// Remove from local state
|
||||
files = files.filter((file) => file.id !== fileId);
|
||||
|
||||
await fetchFiles();
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
deleteStatus = null;
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
const message = (err as Error).message || "An unknown error occurred";
|
||||
deleteStatus = {
|
||||
message: `Failed to delete ${fileName}: ${message}`,
|
||||
isError: true,
|
||||
};
|
||||
console.error("Error deleting file:", err);
|
||||
} finally {
|
||||
deleting = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !$auth.session}
|
||||
<div class="auth-message">
|
||||
<p>Please sign in to access file uploads.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="container">
|
||||
<header class="page-header">
|
||||
<h1 class="page-title">File Upload</h1>
|
||||
</header>
|
||||
|
||||
<div class="form-card">
|
||||
<h2 class="form-title">Upload a File</h2>
|
||||
|
||||
<div class="field-group">
|
||||
<input
|
||||
type="file"
|
||||
bind:this={fileInputRef}
|
||||
onchange={handleFileChange}
|
||||
style="position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0;"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary file-upload-btn"
|
||||
onclick={() => fileInputRef?.click()}
|
||||
>
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
role="img"
|
||||
aria-label="Upload file"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
<p>Click to select a file</p>
|
||||
{#if selectedFile}
|
||||
<p class="file-upload-info">
|
||||
{selectedFile.name} ({formatFileSize(selectedFile.size)})
|
||||
</p>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="error-message">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if uploadResult}
|
||||
<div class="success-message">File uploaded successfully!</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleUpload}
|
||||
disabled={!selectedFile || uploading}
|
||||
class="btn btn-primary"
|
||||
style="width: 100%"
|
||||
>
|
||||
{uploading ? "Uploading..." : "Upload File"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="form-card">
|
||||
<h2 class="form-title">Your Files</h2>
|
||||
|
||||
{#if deleteStatus}
|
||||
<div class={deleteStatus.isError ? "error-message" : "success-message"}>
|
||||
{deleteStatus.message}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isFetching}
|
||||
<div class="loading-container">
|
||||
<div class="loading-content">
|
||||
<div class="spinner"></div>
|
||||
<span class="loading-text">Loading files...</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else if files.length === 0}
|
||||
<div class="empty-state">
|
||||
<svg
|
||||
class="empty-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="empty-title">No files yet</h3>
|
||||
<p class="empty-description">Upload your first file to get started!</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div style="overflow-x: auto">
|
||||
<table class="file-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Size</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each files as file (file.id)}
|
||||
<tr>
|
||||
<td class="file-name">{file.name}</td>
|
||||
<td class="file-meta">{file.mimeType}</td>
|
||||
<td class="file-meta">{formatFileSize(file.size || 0)}</td>
|
||||
<td>
|
||||
<div class="file-actions">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() =>
|
||||
handleViewFile(
|
||||
file.id || "unknown",
|
||||
file.name || "unknown",
|
||||
file.mimeType || "unknown",
|
||||
)}
|
||||
disabled={viewingFile === file.id}
|
||||
class="action-btn action-btn-edit"
|
||||
title="View File"
|
||||
>
|
||||
{viewingFile === file.id ? "⏳" : "👁️"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleDeleteFile(file.id || "unknown")}
|
||||
disabled={deleting === file.id}
|
||||
class="action-btn action-btn-delete"
|
||||
title="Delete File"
|
||||
>
|
||||
{deleting === file.id ? "⏳" : "🗑️"}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Update Navigation Links
|
||||
|
||||
Add a link to the files page in the navigation layout. Update your `src/routes/+layout.svelte` file to include the files link:
|
||||
|
||||
```svelte src/routes/+layout.svelte lines highlight={40}
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
import { auth, initializeAuth, nhost } from "$lib/nhost/auth";
|
||||
import "../app.css";
|
||||
|
||||
let { children }: { children?: import("svelte").Snippet } = $props();
|
||||
|
||||
// Initialize auth when component mounts
|
||||
onMount(() => {
|
||||
return initializeAuth();
|
||||
});
|
||||
|
||||
// Helper function to determine if a link is active
|
||||
function isActive(path: string): string {
|
||||
return $page.url.pathname === path ? "nav-link active" : "nav-link";
|
||||
}
|
||||
|
||||
async function handleSignOut() {
|
||||
if ($auth.session) {
|
||||
await nhost.auth.signOut({
|
||||
refreshToken: $auth.session.refreshToken,
|
||||
});
|
||||
void goto("/");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div id="root">
|
||||
<nav class="navigation">
|
||||
<div class="nav-container">
|
||||
<a href="/" class="nav-logo">Nhost SvelteKit Demo</a>
|
||||
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link">Home</a>
|
||||
|
||||
{#if $auth.isAuthenticated}
|
||||
<a href="/todos" class={isActive('/todos')}>Todos</a>
|
||||
<a href="/files" class={isActive('/files')}>Files</a>
|
||||
<a href="/profile" class={isActive('/profile')}>Profile</a>
|
||||
<button
|
||||
onclick={handleSignOut}
|
||||
class="nav-link nav-button"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
{:else}
|
||||
<a href="/signin" class="nav-link {isActive('/signin')}">
|
||||
Sign In
|
||||
</a>
|
||||
<a href="/signup" class="nav-link {isActive('/signup')}">
|
||||
Sign Up
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="app-content">
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Test Your File Upload System
|
||||
|
||||
Run your SvelteKit application and test all the functionality:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Things to try out:
|
||||
|
||||
1. Try signing in and out and see how the file upload page is only accessible when signed in.
|
||||
2. Upload different types of files (images, documents, etc.)
|
||||
3. View and delete files
|
||||
4. Sign in with another account and verify you cannot see files from the first account
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Key Features Implemented
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Storage Bucket" icon="bucket">
|
||||
Dedicated personal storage bucket with proper configuration for user file isolation.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="File Upload Interface" icon="upload">
|
||||
User-friendly upload interface with file selection, preview, and progress feedback using SvelteKit's reactive patterns.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="File Management" icon="folder">
|
||||
Complete file listing with metadata, viewing capabilities, and deletion functionality.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="File Type Handling" icon="file">
|
||||
Intelligent handling of different file types with appropriate viewing/download behavior.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Error Handling" icon="triangle-exclamation">
|
||||
Comprehensive error handling with user-friendly messages for upload and management operations using Svelte stores.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
@@ -1,497 +0,0 @@
|
||||
---
|
||||
title: Build a Todo Manager with SvelteKit
|
||||
description: Learn how to use Nhost with SvelteKit
|
||||
sidebarTitle: SvelteKit
|
||||
icon: S
|
||||
---
|
||||
|
||||
In this tutorial, you will build a simple **Todo Manager** application with Nhost and React. Along the way you will interact with the Database, Authentication, and Storage services.
|
||||
|
||||
The Todo Manager will allow users to see public `todos` and sign in using a Magic Link to manage their own `todos` with attachments.
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="Database">
|
||||
To store todos
|
||||
</Card>
|
||||
|
||||
<Card title="Auth">
|
||||
To sign in users
|
||||
</Card>
|
||||
|
||||
<Card title="Storage">
|
||||
To store attachments
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
|
||||
## Setup Nhost Backend
|
||||
|
||||
In this section, you will create and setup your first Nhost project.
|
||||
|
||||
### Create project
|
||||
|
||||
Create a new project in the [Nhost Dashboard](https://app.nhost.io).
|
||||
|
||||
Enter the details for your project and wait a couple of minutes while Nhost provisions your backend infrastructure:
|
||||
|
||||
- Dedicated PostgreSQL
|
||||
- Realtime APIs over your data
|
||||
- Authentication for managing your users
|
||||
- Storage for handling files
|
||||
|
||||
### Create table `todos`
|
||||
|
||||
On the project's dashboard, navigate to **Database** and create a new table called `todos`.
|
||||
|
||||

|
||||
|
||||
You can either copy and paste the following SQL into the SQL Editor, **Database -> SQL Editor**, or manually create the table by clicking on **New Table**.
|
||||
|
||||
|
||||
<Tabs>
|
||||
<Tab title="SQL Editor">
|
||||
Copy and paste the following SQL into the SQL Editor and press **Run**.
|
||||
|
||||
<Note>Please make sure to enable **Track this** so that the new table `todos` is available through the auto-generated APIs</Note>
|
||||
|
||||
```sql SQL
|
||||
CREATE TABLE public.todos (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
created_at timestamptz DEFAULT now() NOT NULL,
|
||||
updated_at timestamptz DEFAULT now() NOT NULL,
|
||||
title text NOT NULL,
|
||||
completed bool DEFAULT 'false' NOT NULL,
|
||||
file_id uuid,
|
||||
user_id uuid NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (file_id) REFERENCES storage.files (id) ON UPDATE SET NULL ON DELETE SET NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES auth.users (id) ON UPDATE SET NULL ON DELETE SET NULL
|
||||
);
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="UI">
|
||||
Click on **New Table** and fill in the details for the `todos` table as shown.
|
||||
|
||||

|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
You should now see a new table called `todos` on the left panel, below **New Table**.
|
||||
|
||||
### Set permissions for todos
|
||||
|
||||
It's now time to set permission rules for the table you just created. With the table `todos` selected, click on **...**, followed by **Edit Permissions**.
|
||||
|
||||
You will set permissions for the `user` role and actions `insert`, `select`, `update`, and `delete`.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="insert">
|
||||
Click on the right cell for the `user` role and action `insert` and set permissions as follows:
|
||||

|
||||
</Tab>
|
||||
<Tab title="select">
|
||||
Click on the right cell for the `user` role and action `select` and set permissions as follows:
|
||||

|
||||
</Tab>
|
||||
<Tab title="update">
|
||||
Click on the right cell for the `user` role and action `update` and set permissions as follows:
|
||||

|
||||
</Tab>
|
||||
<Tab title="delete">
|
||||
Click on the right cell for the `user` role and action `delete` and set permissions as follows:
|
||||

|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Set permissions for files
|
||||
|
||||
The `files` table is managed by Nhost and is defined on the `storage` schema. Click on the dropdown right next to `schema.public` and choose `schema.storage`.
|
||||
|
||||
With the `files` table selected, click on **...**, followed by **Edit Permissions**.
|
||||
|
||||
As before, we want to set permissions for the `user` role and actions `insert`, `select`, `delete`.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="insert">
|
||||
Click on the right cell for the `user` role and action `insert` and set permissions as follows:
|
||||

|
||||
</Tab>
|
||||
<Tab title="select">
|
||||
Click on the right cell for the `user` role and action `select` and set permissions as follows:
|
||||

|
||||
</Tab>
|
||||
<Tab title="delete">
|
||||
Click on the right cell for the `user` role and action `delete` and set permissions as follows:
|
||||

|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Enable Sign In with Magic Link
|
||||
|
||||
To enable Magic Links, navigate to your project's **Settings -> Sign-In Methods**, toggle Magic Link, and save.
|
||||
|
||||
### Recap
|
||||
|
||||
<Steps>
|
||||
<Step title="Nhost project created">
|
||||
</Step>
|
||||
|
||||
<Step title="Database todos created">
|
||||
</Step>
|
||||
|
||||
<Step title="Permissions set for todos and files">
|
||||
</Step>
|
||||
|
||||
<Step title="Magic Link enabled">
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Setup React Application
|
||||
|
||||
Now that we have Nhost configured, let's move on to setup the React application and the Nhost client.
|
||||
|
||||
### Create React Application
|
||||
|
||||
Run the following command in your terminal to create a React application using Vite.
|
||||
|
||||
```bash Terminal
|
||||
npm create vite@latest nhost-react -- --template react
|
||||
```
|
||||
|
||||
### Install Nhost React package
|
||||
|
||||
To install Nhost's React package, run the following command.
|
||||
|
||||
```bash Terminal
|
||||
cd nhost-react && npm install @nhost/react
|
||||
```
|
||||
|
||||
#### Configure the Nhost Client
|
||||
|
||||
Create a new file, `./src/lib/nhost.js`, with the following code to create a Nhost client. Replace `<SUBDOMAIN>` and `<REGION>` with the values from the project created earlier.
|
||||
|
||||
```ts ./src/lib/nhost.ts
|
||||
import { NhostClient } from "@nhost/react";
|
||||
|
||||
export const nhost = new NhostClient({
|
||||
subdomain: "<SUBDOMAIN>",
|
||||
region: "<REGION>"
|
||||
});
|
||||
```
|
||||
|
||||
<Info>The project's `subdomain` and `region` can be found in the Nhost Dashboard under **Project Info**</Info>
|
||||
|
||||
### Setup Sign In Component
|
||||
|
||||
It is time to setup a new React component to handle the login functionality. Users will be able to sign in using a Magic Link.
|
||||
|
||||
Create a new file `./src/signin.jsx` with the following content:
|
||||
|
||||
```js ./src/signin.jsx
|
||||
import { useState } from 'react'
|
||||
import { useSignInEmailPasswordless } from '@nhost/react'
|
||||
|
||||
export default function SignIn() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [email, setEmail] = useState('')
|
||||
|
||||
const { signInEmailPasswordless, error } = useSignInEmailPasswordless()
|
||||
|
||||
const handleSignIn = async (event) => {
|
||||
event.preventDefault()
|
||||
|
||||
setLoading(true)
|
||||
const { error } = await signInEmailPasswordless(email)
|
||||
|
||||
if (error) {
|
||||
console.error({ error })
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
alert('Magic Link Sent!')
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Todo Manager</h1>
|
||||
<p>powered by Nhost and React</p>
|
||||
<form onSubmit={handleSignIn}>
|
||||
<div>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Your email"
|
||||
value={email}
|
||||
required={true}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button disabled={loading}>
|
||||
{loading ? <span>Loading</span> : <span>Send me a Magic Link!</span>}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p>{error.message}</p>}
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Setup `Todos` Component
|
||||
|
||||
Now that users can sign in, let's move on and create the authenticated page that lists a user's todos and has a form for managing todos with attachments.
|
||||
|
||||
```js ./src/todos.jsx
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNhostClient, useFileUpload } from '@nhost/react'
|
||||
|
||||
const deleteTodo = `
|
||||
mutation($id: uuid!) {
|
||||
delete_todos_by_pk(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
const createTodo = `
|
||||
mutation($title: String!, $file_id: uuid) {
|
||||
insert_todos_one(object: {title: $title, file_id: $file_id}) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
const getTodos = `
|
||||
query {
|
||||
todos {
|
||||
id
|
||||
title
|
||||
file_id
|
||||
completed
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export default function Todos() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [todos, setTodos] = useState([])
|
||||
|
||||
const [todoTitle, setTodoTitle] = useState('')
|
||||
const [todoAttachment, setTodoAttachment] = useState(null)
|
||||
const [fetchAll, setFetchAll] = useState(false)
|
||||
|
||||
const nhostClient = useNhostClient()
|
||||
const { upload } = useFileUpload()
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchTodos() {
|
||||
setLoading(true)
|
||||
const { data, error } = await nhostClient.graphql.request(getTodos)
|
||||
|
||||
if (error) {
|
||||
console.error({ error })
|
||||
return
|
||||
}
|
||||
|
||||
setTodos(data.todos)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
fetchTodos()
|
||||
|
||||
return () => {
|
||||
setFetchAll(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fetchAll])
|
||||
|
||||
const handleCreateTodo = async (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
let todo = { title: todoTitle }
|
||||
if (todoAttachment) {
|
||||
const { id, error } = await upload({
|
||||
file: todoAttachment,
|
||||
name: todoAttachment.name
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error({ error })
|
||||
return
|
||||
}
|
||||
|
||||
todo.file_id = id
|
||||
}
|
||||
|
||||
const { error } = await nhostClient.graphql.request(createTodo, todo)
|
||||
|
||||
if (error) {
|
||||
console.error({ error })
|
||||
}
|
||||
|
||||
setTodoTitle('')
|
||||
setTodoAttachment(null)
|
||||
setFetchAll(true)
|
||||
}
|
||||
|
||||
const handleDeleteTodo = async (id) => {
|
||||
if (!window.confirm('Are you sure you want to delete this TODO?')) {
|
||||
return
|
||||
}
|
||||
|
||||
const todo = todos.find((todo) => todo.id === id)
|
||||
if (todo.file_id) {
|
||||
await nhostClient.storage.delete({ fileId: todo.file_id })
|
||||
}
|
||||
|
||||
const { error } = await nhostClient.graphql.request(deleteTodo, { id })
|
||||
if (error) {
|
||||
console.error({ error })
|
||||
}
|
||||
|
||||
setFetchAll(true)
|
||||
}
|
||||
|
||||
const completeTodo = async (id) => {
|
||||
const { error } = await nhostClient.graphql.request(
|
||||
`
|
||||
mutation($id: uuid!) {
|
||||
update_todos_by_pk(pk_columns: {id: $id}, _set: {completed: true}) {
|
||||
completed
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ id }
|
||||
)
|
||||
|
||||
if (error) {
|
||||
console.error({ error })
|
||||
}
|
||||
|
||||
setFetchAll(true)
|
||||
}
|
||||
|
||||
const openAttachment = async (todo) => {
|
||||
const { presignedUrl, error } = await nhostClient.storage.getPresignedUrl({
|
||||
fileId: todo.file_id
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error({ error })
|
||||
return
|
||||
}
|
||||
|
||||
window.open(presignedUrl.url, '_blank')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="container">
|
||||
<div className="form-section">
|
||||
<h2>Add a new TODO</h2>
|
||||
<form onSubmit={handleCreateTodo}>
|
||||
<div className="input-group">
|
||||
<label htmlFor="title">Title</label>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
value={todoTitle}
|
||||
onChange={(e) => setTodoTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label htmlFor="file">File (optional)</label>
|
||||
<input id="file" type="file" onChange={(e) => setTodoAttachment(e.target.files[0])} />
|
||||
</div>
|
||||
<div className="submit-group">
|
||||
<button type="submit" disabled={!todoTitle}>
|
||||
Add Todo
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="todos-section">
|
||||
{(!loading &&
|
||||
todos.map((todo) => (
|
||||
<div className="todo-item" key={todo.id ?? 0}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={todo.completed}
|
||||
disabled={todo.completed}
|
||||
id={`todo-${todo.id}`}
|
||||
onChange={() => completeTodo(todo.id)}
|
||||
/>
|
||||
{todo.file_id && (
|
||||
<span>
|
||||
<a onClick={() => openAttachment(todo)}> Open Attachment</a>
|
||||
</span>
|
||||
)}
|
||||
<label htmlFor={`todo-${todo.id}`} className="todo-title">
|
||||
{todo.completed && <s>{todo.title}</s>}
|
||||
{!todo.completed && todo.title}
|
||||
</label>
|
||||
<button type="button" onClick={() => handleDeleteTodo(todo.id)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
))) || (
|
||||
<div className="todo-item">
|
||||
<label className="todo-title">Loading...</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sign-out-section">
|
||||
<button type="button" onClick={() => nhostClient.auth.signOut()}>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
With both `SignIn` and `Todos` in place, update `./src/App.jsx` to use the new components:
|
||||
|
||||
```js ./src/App.jsx
|
||||
import './App.css'
|
||||
import { NhostProvider } from '@nhost/react'
|
||||
import { nhost } from './lib/nhost.js'
|
||||
import SignIn from './signin'
|
||||
import Todos from './todos'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
function App() {
|
||||
const [session, setSession] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
setSession(nhost.auth.getSession())
|
||||
|
||||
nhost.auth.onAuthStateChanged((_, session) => {
|
||||
setSession(session)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<NhostProvider nhost={nhost}>
|
||||
{session ? <Todos session={session} /> : <SignIn />}
|
||||
</NhostProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
```
|
||||
|
||||
|
||||
## The End
|
||||
|
||||
Run the Todo Manager with:
|
||||
|
||||
```bash Terminal
|
||||
npm run dev -- --open --port 3000
|
||||
```
|
||||
|
||||
Open your browser on [localhost:3000](localhost:3000) to see your new application in action.
|
||||
|
||||
@@ -1,504 +0,0 @@
|
||||
---
|
||||
title: Build a Todo Manager with Vue
|
||||
description: Learn how to use Nhost with Vue
|
||||
sidebarTitle: Vue
|
||||
icon: vuejs
|
||||
---
|
||||
|
||||
In this tutorial, you will build a simple **Todo Manager** with Vue and Nhost. The Todo Manager will allow users to sign in using a Magic Link and manage their own Todos with attachments.
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="Database">
|
||||
To store todos
|
||||
</Card>
|
||||
|
||||
<Card title="Auth">
|
||||
To sign in users
|
||||
</Card>
|
||||
|
||||
<Card title="Storage">
|
||||
To store attachments
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
|
||||
|
||||
## Setup Nhost Backend
|
||||
|
||||
In this section, you will create and setup your first Nhost project.
|
||||
|
||||
### Create project
|
||||
|
||||
Create a new project in the [Nhost Dashboard](https://app.nhost.io).
|
||||
|
||||
Enter the details for your project and wait a couple of minutes while Nhost provisions your backend infrastructure:
|
||||
|
||||
- Dedicated PostgreSQL
|
||||
- Realtime APIs over your data
|
||||
- Authentication for managing your users
|
||||
- Storage for handling files
|
||||
|
||||
### Create table todos
|
||||
|
||||
On the project's dashboard, navigate to **Database** and create a new table called `todos`.
|
||||
|
||||

|
||||
|
||||
You can either copy and paste the following SQL into the SQL Editor, **Database -> SQL Editor**, or manually create the table by clicking on **New Table**.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="SQL Editor">
|
||||
Copy and paste the following SQL into the SQL Editor and press **Run**.
|
||||
|
||||
<Note>Please make sure to enable **Track this** so that the new table `todos` is available through the auto-generated APIs</Note>
|
||||
|
||||
```sql SQL
|
||||
CREATE TABLE public.todos (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
created_at timestamptz DEFAULT now() NOT NULL,
|
||||
updated_at timestamptz DEFAULT now() NOT NULL,
|
||||
title text NOT NULL,
|
||||
completed bool DEFAULT 'false' NOT NULL,
|
||||
file_id uuid,
|
||||
user_id uuid NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (file_id) REFERENCES storage.files (id) ON UPDATE SET NULL ON DELETE SET NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES auth.users (id) ON UPDATE SET NULL ON DELETE SET NULL
|
||||
);
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="UI">
|
||||
Click on **New Table** and fill in the details for the `todos` table as shown.
|
||||
|
||||

|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
You should now see a new table called `todos` on the left panel, above **New Table**.
|
||||
|
||||
### Set permissions for todos
|
||||
|
||||
It's now time to set permission rules for the table you just created. With the table `todos` selected, click on **...**, followed by **Edit Permissions**.
|
||||
|
||||
You will set permissions for the `user` role and actions `insert`, `select`, `update`, and `delete`.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="insert">
|
||||
Click on the right cell for the `user` role and action `insert` and set permissions as follows:
|
||||

|
||||
</Tab>
|
||||
<Tab title="select">
|
||||
Click on the right cell for the `user` role and action `select` and set permissions as follows:
|
||||

|
||||
</Tab>
|
||||
<Tab title="update">
|
||||
Click on the right cell for the `user` role and action `update` and set permissions as follows:
|
||||

|
||||
</Tab>
|
||||
<Tab title="delete">
|
||||
Click on the right cell for the `user` role and action `delete` and set permissions as follows:
|
||||

|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Set permissions for files
|
||||
|
||||
The `files` table is managed by Nhost and is defined on the `storage` schema. Click on the dropdown right next to `schema.public` and choose `schema.storage`.
|
||||
|
||||
With the `files` table selected, click on **...**, followed by **Edit Permissions**.
|
||||
|
||||
As before, we want to set permissions for the `user` role and actions `insert`, `select`, `delete`.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="insert">
|
||||
Click on the right cell for the `user` role and action `insert` and set permissions as follows:
|
||||

|
||||
</Tab>
|
||||
<Tab title="select">
|
||||
Click on the right cell for the `user` role and action `select` and set permissions as follows:
|
||||

|
||||
</Tab>
|
||||
<Tab title="delete">
|
||||
Click on the right cell for the `user` role and action `delete` and set permissions as follows:
|
||||

|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Enable Sign In with Magic Link
|
||||
|
||||
To enable Magic Links, navigate to your project's **Settings -> Sign-In Methods**, toggle Magic Link, and save.
|
||||
|
||||
### Recap
|
||||
|
||||
<Steps>
|
||||
<Step title="Nhost project created">
|
||||
</Step>
|
||||
|
||||
<Step title="Database todos created">
|
||||
</Step>
|
||||
|
||||
<Step title="Permissions set for todos and files">
|
||||
</Step>
|
||||
|
||||
<Step title="Magic Link enabled">
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
|
||||
## Setup Vue Application
|
||||
|
||||
Now that we have Nhost configured, let's move on to setup the Vue application and the Nhost client.
|
||||
|
||||
### Create Vue Application
|
||||
|
||||
Run the following command in your terminal to create a Vue application using Vite.
|
||||
|
||||
```bash Terminal
|
||||
npm create vue@latest nhost-vue
|
||||
```
|
||||
|
||||
### Install Nhost Vue package
|
||||
|
||||
To install Nhost's Vue package, run the following command.
|
||||
|
||||
```bash Terminal
|
||||
cd nhost-vue && npm install @nhost/vue
|
||||
```
|
||||
|
||||
#### Configure the Nhost Client
|
||||
|
||||
Create a new file `./src/lib/nhost.js` with the following code to create a Nhost client. Replace `<subdomain>` and `<region>` with the values for the project you created earlier.
|
||||
|
||||
```js ./src/lib/nhost.js
|
||||
import { NhostClient } from "@nhost/vue";
|
||||
|
||||
export const nhost = new NhostClient({
|
||||
subdomain: "<SUBDOMAIN>",
|
||||
region: "<REGION>"
|
||||
});
|
||||
```
|
||||
|
||||
<Info>The project's `subdomain` and `region` can be found in the Nhost Dashboard under **Project Info**</Info>
|
||||
|
||||
### Setup Sign In Component
|
||||
|
||||
It is time to setup a new React component to handle the login functionality. Your users will be able to sign in using a Magic Link and without a password.
|
||||
|
||||
Create a new file `./src/SignIn.vue` for the Sign In component with the following content:
|
||||
|
||||
```js ./src/SignIn.vue
|
||||
<template>
|
||||
<div>
|
||||
<h1>Todo Manager</h1>
|
||||
<p>powered by Nhost and Vue</p>
|
||||
<form @submit.prevent="handleSignIn">
|
||||
<div>
|
||||
<input type="email" placeholder="Your email" v-model="email" required />
|
||||
</div>
|
||||
<div>
|
||||
<button :disabled="loading">
|
||||
<span v-if="loading">Loading</span>
|
||||
<span v-else>Send me a Magic Link!</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="error">{{ error.message }}</p>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref } from "vue";
|
||||
import { useSignInEmailPasswordless } from "@nhost/vue";
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const email = ref("");
|
||||
const { signInEmailPasswordless, error } = useSignInEmailPasswordless();
|
||||
const loading = ref(false);
|
||||
|
||||
const handleSignIn = async () => {
|
||||
loading.value = true;
|
||||
const { error } = await signInEmailPasswordless(email.value);
|
||||
if (error) {
|
||||
console.error({ error });
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
loading.value = false;
|
||||
alert("Magic Link Sent!");
|
||||
};
|
||||
|
||||
return { email, handleSignIn, loading, error };
|
||||
},
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
### Setup Todos Component
|
||||
|
||||
Now that users can sign in, go ahead and create the authenticated page that lists a user's todos and has a form for managing todos with attachments.
|
||||
|
||||
```js ./src/Todos.vue
|
||||
<template>
|
||||
<div class="container">
|
||||
<div class="form-section">
|
||||
<h2>Add a new TODO</h2>
|
||||
<form @submit.prevent="handleCreateTodo">
|
||||
<div class="input-group">
|
||||
<label for="title">Title</label>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
v-model="todoTitle"
|
||||
/>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="file">File (optional)</label>
|
||||
<input
|
||||
id="file"
|
||||
type="file"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="submit-group">
|
||||
<button type="submit" :disabled="!todoTitle">
|
||||
Add Todo
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="todos-section">
|
||||
<div
|
||||
class="todo-item"
|
||||
v-for="todo in todos"
|
||||
:key="todo.id"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="todo.completed"
|
||||
:disabled="todo.completed"
|
||||
:id="`todo-${todo.id}`"
|
||||
@change="() => completeTodo(todo.id)"
|
||||
/>
|
||||
<span v-if="todo.file_id">
|
||||
<a @click="() => openAttachment(todo)">Open Attachment</a>
|
||||
</span>
|
||||
<label :for="`todo-${todo.id}`" class="todo-title">
|
||||
<s v-if="todo.completed">{{ todo.title }}</s>
|
||||
<span v-else>{{ todo.title }}</span>
|
||||
</label>
|
||||
<button type="button" @click="() => handleDeleteTodo(todo.id)">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<div class="todo-item" v-if="loading">
|
||||
<label class="todo-title">Loading...</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useNhostClient, useFileUpload } from '@nhost/vue';
|
||||
|
||||
const getTodos = `
|
||||
query {
|
||||
todos {
|
||||
id
|
||||
title
|
||||
file_id
|
||||
completed
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const createTodo = `
|
||||
mutation($title: String!, $file_id: uuid) {
|
||||
insert_todos_one(object: {title: $title, file_id: $file_id}) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const deleteTodo = `
|
||||
mutation($id: uuid!) {
|
||||
delete_todos_by_pk(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const { nhost } = useNhostClient();
|
||||
const { upload } = useFileUpload();
|
||||
const todos = ref([]);
|
||||
const todoTitle = ref('');
|
||||
const todoAttachment = ref(null);
|
||||
const loading = ref(true);
|
||||
|
||||
const fetchTodos = async () => {
|
||||
loading.value = true;
|
||||
const { data, error } = await nhost.graphql.request(getTodos);
|
||||
|
||||
if (error) {
|
||||
console.error({ error });
|
||||
return;
|
||||
}
|
||||
|
||||
todos.value = data.todos;
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
onMounted(fetchTodos);
|
||||
|
||||
const handleCreateTodo = async () => {
|
||||
let todo = { title: todoTitle.value };
|
||||
if (todoAttachment.value) {
|
||||
const { id, error } = await upload({
|
||||
file: todoAttachment.value,
|
||||
name: todoAttachment.value.name
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error({ error });
|
||||
return;
|
||||
}
|
||||
|
||||
todo.file_id = id;
|
||||
}
|
||||
|
||||
const { error } = await nhost.graphql.request(createTodo, todo);
|
||||
|
||||
if (error) {
|
||||
console.error({ error });
|
||||
}
|
||||
|
||||
todoTitle.value = '';
|
||||
todoAttachment.value = null;
|
||||
fetchTodos();
|
||||
};
|
||||
|
||||
const handleDeleteTodo = async (id) => {
|
||||
if (!window.confirm('Are you sure you want to delete this TODO?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const todo = todos.value.find((t) => t.id === id);
|
||||
if (todo && todo.file_id) {
|
||||
await nhost.storage.delete({ fileId: todo.file_id });
|
||||
}
|
||||
|
||||
const { error } = await nhost.graphql.request(deleteTodo, { id });
|
||||
if (error) {
|
||||
console.error({ error });
|
||||
}
|
||||
|
||||
fetchTodos();
|
||||
};
|
||||
|
||||
const completeTodo = async (id) => {
|
||||
const { error } = await nhost.graphql.request(
|
||||
`
|
||||
mutation($id: uuid!) {
|
||||
update_todos_by_pk(pk_columns: {id: $id}, _set: {completed: true}) {
|
||||
completed
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ id }
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error({ error });
|
||||
}
|
||||
|
||||
fetchTodos();
|
||||
};
|
||||
|
||||
const openAttachment = async (todo) => {
|
||||
const { presignedUrl, error } = await nhost.storage.getPresignedUrl({
|
||||
fileId: todo.file_id
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error({ error });
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(presignedUrl.url, '_blank');
|
||||
};
|
||||
|
||||
return {
|
||||
todos,
|
||||
todoTitle,
|
||||
todoAttachment,
|
||||
loading,
|
||||
handleCreateTodo,
|
||||
handleDeleteTodo,
|
||||
completeTodo,
|
||||
openAttachment
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
With both `SignIn` and `Todos` in place, update `./src/App.vue` to use the new components:
|
||||
|
||||
```js ./src/App.vue
|
||||
<template>
|
||||
<Todos v-if="session" :session="session" />
|
||||
<SignIn v-else />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import SignIn from './SignIn.vue';
|
||||
import Todos from './Todos.vue';
|
||||
import { useNhostClient } from '@nhost/vue';
|
||||
|
||||
export default {
|
||||
components: { SignIn, Todos },
|
||||
setup() {
|
||||
const session = ref(null);
|
||||
const { nhost } = useNhostClient()
|
||||
|
||||
onMounted(() => {
|
||||
session.value = nhost.auth.getSession();
|
||||
nhost.auth.onAuthStateChanged((_, newSession) => {
|
||||
session.value = newSession;
|
||||
});
|
||||
});
|
||||
|
||||
return { session };
|
||||
},
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
The last step missing is to install `nhost` as a plugin:
|
||||
|
||||
```js ./src/main.js
|
||||
import "./assets/main.css";
|
||||
import { nhost } from "./lib/nhost";
|
||||
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
|
||||
createApp(App).use(nhost).mount("#app");
|
||||
```
|
||||
|
||||
## The End
|
||||
|
||||
Run the Todo Manager with:
|
||||
|
||||
```bash Terminal
|
||||
npm run dev -- --open --port 3000
|
||||
```
|
||||
|
||||
Open your browser on [localhost:3000](localhost:3000) to see your new application in action.
|
||||
|
||||
116
docs/getting-started/tutorials/vue/1-introduction.mdx
Normal file
@@ -0,0 +1,116 @@
|
||||
---
|
||||
title: Create Your Nhost Project
|
||||
description: Learn how to create and set up a new Nhost project to get started building your Vue application
|
||||
sidebarTitle: Create Project
|
||||
icon: plus
|
||||
---
|
||||
|
||||
Welcome to the **Full-Stack Vue Development with Nhost** series! In this comprehensive tutorial series, you'll build a complete Vue application with Nhost that demonstrates authentication, database operations, and file management.
|
||||
|
||||
## About This Tutorial Series
|
||||
|
||||
This tutorial series is divided into **5 parts**, each focusing on a specific aspect of building modern web applications with Nhost and Vue. By the end of the series, you'll have built a fully functional application featuring:
|
||||
|
||||
- **User Authentication** - Complete sign up, sign in, and email verification flow
|
||||
- **Todo Management** - Users can create, update, delete, and mark todos as complete
|
||||
- **File Uploads** - Users can upload and manage files with proper permissions
|
||||
- **Protected Routes** - Secure areas that only authenticated users can access
|
||||
|
||||
<Info>
|
||||
This is **Part 1** in the Full-Stack Vue Development with Nhost series. This part sets up the foundation by creating your Nhost project and understanding the series structure.
|
||||
</Info>
|
||||
|
||||
## Full-Stack Vue Development with Nhost
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/vue/1-introduction">
|
||||
**Current** - Set up your Nhost project
|
||||
</Card>
|
||||
|
||||
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/vue/2-protected-routes">
|
||||
Route protection basics
|
||||
</Card>
|
||||
|
||||
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/vue/3-user-authentication">
|
||||
Complete auth flow
|
||||
</Card>
|
||||
|
||||
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/vue/4-graphql-operations">
|
||||
CRUD operations with GraphQL
|
||||
</Card>
|
||||
|
||||
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/vue/5-file-uploads">
|
||||
File upload and management
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## What You'll Learn
|
||||
|
||||
Throughout this series, you'll master:
|
||||
|
||||
- Setting up and configuring Nhost projects
|
||||
- Implementing secure authentication flows
|
||||
- Building protected routes with Vue Router
|
||||
- Performing GraphQL queries and mutations
|
||||
- Managing file uploads and storage
|
||||
- Configuring database permissions and security
|
||||
- Building responsive Vue interfaces
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 20+ installed on your machine
|
||||
- Basic knowledge of Vue and JavaScript
|
||||
- Understanding of modern web development concepts
|
||||
|
||||
Creating an Nhost project is the first step to building your application with Nhost. Let's get started by setting up your backend infrastructure.
|
||||
|
||||
## Step-by-Step Guide
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
|
||||
### Sign Up or Log in
|
||||
|
||||
If you don't have an Nhost account, sign up at [Nhost](https://app.nhost.io/). If you already have an account, log in.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Create a New Project
|
||||
|
||||
Click on the "Create Project" button on your dashboard or follow the onboarding prompts if you're a new user.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Take note of your project subdomain and region
|
||||
|
||||
Take note of your project subdomain and region. You will need this information to connect your application to the Nhost backend in upcoming tutorials.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
## What's Next?
|
||||
|
||||
With your Nhost project created, you now have access to:
|
||||
|
||||
- [**PostgreSQL Database**](/products/database/overview) - For storing your application data
|
||||
- [**Authentication Service**](/products/auth/overview) - For managing users and sessions
|
||||
- [**GraphQL API**](/products/graphql/overview) - For querying and mutating data
|
||||
- [**File Storage**](/products/storage/overview) - For uploading and managing files
|
||||
- [**Functions**](/products/functions/overview) - For running serverless functions
|
||||
|
||||
In the [next tutorial](/getting-started/tutorials/vue/2-protected-routes), you'll start building your Vue application and learn how to protect routes based on user authentication status.
|
||||
|
||||
<Tip>
|
||||
Keep your project subdomain and region handy - you'll need them throughout the series to connect your Vue application to the Nhost backend.
|
||||
</Tip>
|
||||
1368
docs/getting-started/tutorials/vue/2-protected-routes.mdx
Normal file
637
docs/getting-started/tutorials/vue/3-user-authentication.mdx
Normal file
@@ -0,0 +1,637 @@
|
||||
---
|
||||
title: User Authentication in Vue
|
||||
description: Learn how to implement user authentication in a Vue application using Nhost
|
||||
sidebarTitle: "User Authentication"
|
||||
icon: user
|
||||
---
|
||||
|
||||
This tutorial part builds upon the [Protected Routes part](/getting-started/tutorials/vue/2-protected-routes) by adding complete email/password authentication with email verification functionality. You'll implement sign up, sign in, email verification, and sign out features to create a full authentication flow.
|
||||
|
||||
<Info>
|
||||
This is **Part 3** in the Full-Stack Vue Development with Nhost series. This part creates a production-ready authentication system with secure email verification and proper error handling.
|
||||
</Info>
|
||||
|
||||
## Full-Stack Vue Development with Nhost
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/vue/1-introduction">
|
||||
Set up your Nhost project
|
||||
</Card>
|
||||
|
||||
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/vue/2-protected-routes">
|
||||
Route protection basics
|
||||
</Card>
|
||||
|
||||
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/vue/3-user-authentication">
|
||||
**Current** - Complete auth flow
|
||||
</Card>
|
||||
|
||||
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/vue/4-graphql-operations">
|
||||
CRUD operations with GraphQL
|
||||
</Card>
|
||||
|
||||
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/vue/5-file-uploads">
|
||||
File upload and management
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Complete the [Protected Routes part](/getting-started/tutorials/vue/2-protected-routes) first
|
||||
- The project from the previous part set up and running
|
||||
|
||||
## Step-by-Step Guide
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
|
||||
### Create the Sign In Page
|
||||
|
||||
Build a comprehensive sign-in form with proper error handling and loading states. This page handles user authentication and includes special logic for post-verification sign-in.
|
||||
|
||||
```vue src/views/SignIn.vue lines
|
||||
<template>
|
||||
<div>
|
||||
<h1>Sign In</h1>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="auth-form">
|
||||
<div class="auth-form-field">
|
||||
<label :for="emailId">Email</label>
|
||||
<input
|
||||
:id="emailId"
|
||||
type="email"
|
||||
v-model="email"
|
||||
required
|
||||
class="auth-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="auth-form-field">
|
||||
<label :for="passwordId">Password</label>
|
||||
<input
|
||||
:id="passwordId"
|
||||
type="password"
|
||||
v-model="password"
|
||||
required
|
||||
class="auth-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="auth-error">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isLoading"
|
||||
class="auth-button secondary"
|
||||
>
|
||||
{{ isLoading ? "Signing In..." : "Sign In" }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-links">
|
||||
<p>
|
||||
Don't have an account? <router-link to="/signup">Sign Up</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, useId } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useAuth } from "../lib/nhost/auth";
|
||||
|
||||
const { nhost, isAuthenticated } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const email = ref("");
|
||||
const password = ref("");
|
||||
const isLoading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const emailId = useId();
|
||||
const passwordId = useId();
|
||||
|
||||
// Use onMounted for navigation after authentication is confirmed
|
||||
onMounted(() => {
|
||||
if (isAuthenticated.value) {
|
||||
router.push("/profile");
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
// Use the signIn function from auth context
|
||||
const response = await nhost.auth.signInEmailPassword({
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
});
|
||||
|
||||
// If we have a session, sign in was successful
|
||||
if (response.body?.session) {
|
||||
router.push("/profile");
|
||||
} else {
|
||||
error.value = "Failed to sign in. Please check your credentials.";
|
||||
}
|
||||
} catch (err) {
|
||||
const message = (err as Error).message || "Unknown error";
|
||||
error.value = `An error occurred during sign in: ${message}`;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Create the Sign Up Page
|
||||
|
||||
Implement user registration with email verification flow. This page collects user information, creates accounts, and guides users through the email verification process.
|
||||
|
||||
```vue src/views/SignUp.vue lines
|
||||
<template>
|
||||
<div>
|
||||
<h1 v-if="!success">Sign Up</h1>
|
||||
<h1 v-else>Check Your Email</h1>
|
||||
|
||||
<div v-if="success" class="success-message">
|
||||
<p>
|
||||
We've sent a verification link to <strong>{{ email }}</strong>
|
||||
</p>
|
||||
<p>
|
||||
Please check your email and click the verification link to activate your account.
|
||||
</p>
|
||||
<p>
|
||||
<router-link to="/signin">Back to Sign In</router-link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form v-else @submit.prevent="handleSubmit" class="auth-form">
|
||||
<div class="auth-form-field">
|
||||
<label :for="displayNameId">Display Name</label>
|
||||
<input
|
||||
:id="displayNameId"
|
||||
type="text"
|
||||
v-model="displayName"
|
||||
required
|
||||
class="auth-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="auth-form-field">
|
||||
<label :for="emailId">Email</label>
|
||||
<input
|
||||
:id="emailId"
|
||||
type="email"
|
||||
v-model="email"
|
||||
required
|
||||
class="auth-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="auth-form-field">
|
||||
<label :for="passwordId">Password</label>
|
||||
<input
|
||||
:id="passwordId"
|
||||
type="password"
|
||||
v-model="password"
|
||||
required
|
||||
minlength="8"
|
||||
class="auth-input"
|
||||
/>
|
||||
<small class="help-text">Minimum 8 characters</small>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="auth-error">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isLoading"
|
||||
class="auth-button primary"
|
||||
>
|
||||
{{ isLoading ? "Creating Account..." : "Sign Up" }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div v-if="!success" class="auth-links">
|
||||
<p>
|
||||
Already have an account? <router-link to="/signin">Sign In</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, useId } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useAuth } from "../lib/nhost/auth";
|
||||
|
||||
const { nhost, isAuthenticated } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const email = ref("");
|
||||
const password = ref("");
|
||||
const displayName = ref("");
|
||||
const isLoading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const success = ref(false);
|
||||
|
||||
const displayNameId = useId();
|
||||
const emailId = useId();
|
||||
const passwordId = useId();
|
||||
|
||||
// Redirect authenticated users to profile
|
||||
onMounted(() => {
|
||||
if (isAuthenticated.value) {
|
||||
router.push("/profile");
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
success.value = false;
|
||||
|
||||
try {
|
||||
const response = await nhost.auth.signUpEmailPassword({
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
options: {
|
||||
displayName: displayName.value,
|
||||
// Set the redirect URL for email verification
|
||||
redirectTo: `${window.location.origin}/verify`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.body?.session) {
|
||||
// Successfully signed up and automatically signed in
|
||||
router.push("/profile");
|
||||
} else {
|
||||
// Verification email sent
|
||||
success.value = true;
|
||||
}
|
||||
} catch (err) {
|
||||
const message = (err as Error).message || "Unknown error";
|
||||
error.value = `An error occurred during sign up: ${message}`;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Create the Email Verification Page
|
||||
|
||||
Build a dedicated verification page that processes email verification tokens. This page handles the verification flow when users click the email verification link.
|
||||
|
||||
```vue src/views/Verify.vue lines
|
||||
<template>
|
||||
<div>
|
||||
<h1>Email Verification</h1>
|
||||
|
||||
<div class="page-center">
|
||||
<div v-if="status === 'verifying'">
|
||||
<p class="margin-bottom">Verifying your email...</p>
|
||||
<div class="spinner-verify" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="status === 'success'">
|
||||
<p class="verification-status">
|
||||
✓ Successfully verified!
|
||||
</p>
|
||||
<p>You'll be redirected to your profile page shortly...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="status === 'error'">
|
||||
<p class="verification-status error">
|
||||
Verification failed
|
||||
</p>
|
||||
<p class="margin-bottom">{{ error }}</p>
|
||||
|
||||
<div v-if="Object.keys(urlParams).length > 0" class="debug-panel">
|
||||
<p class="debug-title">
|
||||
URL Parameters:
|
||||
</p>
|
||||
<div
|
||||
v-for="[key, value] in Object.entries(urlParams)"
|
||||
:key="key"
|
||||
class="debug-item"
|
||||
>
|
||||
<span class="debug-key">
|
||||
{{ key }}:
|
||||
</span>
|
||||
<span class="debug-value">{{ value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="router.push('/signin')"
|
||||
class="auth-button secondary"
|
||||
>
|
||||
Back to Sign In
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useAuth } from "../lib/nhost/auth";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { nhost } = useAuth();
|
||||
|
||||
const status = ref<"verifying" | "success" | "error">("verifying");
|
||||
const error = ref<string | null>(null);
|
||||
const urlParams = ref<Record<string, string>>({});
|
||||
|
||||
// Flag to handle component unmounting during async operations
|
||||
let isMounted = true;
|
||||
|
||||
onMounted(() => {
|
||||
// Extract the refresh token from the URL
|
||||
const params = new URLSearchParams(route.fullPath.split("?")[1] || "");
|
||||
const refreshToken = params.get("refreshToken");
|
||||
|
||||
if (!refreshToken) {
|
||||
// Collect all URL parameters to display for debugging
|
||||
const allParams: Record<string, string> = {};
|
||||
params.forEach((value, key) => {
|
||||
allParams[key] = value;
|
||||
});
|
||||
urlParams.value = allParams;
|
||||
|
||||
status.value = "error";
|
||||
error.value = "No refresh token found in URL";
|
||||
return;
|
||||
}
|
||||
|
||||
processToken(refreshToken, params);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
isMounted = false;
|
||||
});
|
||||
|
||||
async function processToken(
|
||||
refreshToken: string,
|
||||
params: URLSearchParams,
|
||||
): 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> = {};
|
||||
params.forEach((value, key) => {
|
||||
allParams[key] = value;
|
||||
});
|
||||
urlParams.value = allParams;
|
||||
|
||||
status.value = "error";
|
||||
error.value = "No refresh token found in URL";
|
||||
return;
|
||||
}
|
||||
|
||||
// Process the token
|
||||
await nhost.auth.refreshToken({ refreshToken });
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
status.value = "success";
|
||||
|
||||
// Wait to show success message briefly, then redirect
|
||||
setTimeout(() => {
|
||||
if (isMounted) router.push("/profile");
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
const message = (err as Error).message || "Unknown error";
|
||||
if (!isMounted) return;
|
||||
|
||||
status.value = "error";
|
||||
error.value = `An error occurred during verification: ${message}`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
<Warning>
|
||||
**Important Configuration Required:** Before testing email verification, you must configure your Nhost project's authentication settings:
|
||||
|
||||
1. Go to your Nhost project dashboard
|
||||
2. Navigate to **Settings → Authentication**
|
||||
3. Add your local development URL (e.g., `http://localhost:5173`) to the **Allowed Redirect URLs** field
|
||||
4. Ensure your production domain is also added when deploying
|
||||
|
||||
Without this configuration, you'll receive a `redirectTo not allowed` error when users attempt to sign up or verify their email addresses.
|
||||
</Warning>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Update Router Configuration
|
||||
|
||||
Update your router configuration to include the new authentication routes:
|
||||
|
||||
```ts src/router/index.ts lines highlight={5-7,17-31}
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import { useAuth } from "../lib/nhost/auth";
|
||||
import HomeView from "../views/HomeView.vue";
|
||||
import ProfileView from "../views/ProfileView.vue";
|
||||
import SignIn from "../views/SignIn.vue";
|
||||
import SignUp from "../views/SignUp.vue";
|
||||
import Verify from "../views/Verify.vue";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: "/",
|
||||
name: "home",
|
||||
component: HomeView,
|
||||
},
|
||||
{
|
||||
path: "/signin",
|
||||
name: "SignIn",
|
||||
component: SignIn,
|
||||
},
|
||||
{
|
||||
path: "/signup",
|
||||
name: "SignUp",
|
||||
component: SignUp,
|
||||
},
|
||||
{
|
||||
path: "/verify",
|
||||
name: "Verify",
|
||||
component: Verify,
|
||||
},
|
||||
{
|
||||
path: "/profile",
|
||||
name: "profile",
|
||||
component: ProfileView,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/:pathMatch(.*)*",
|
||||
redirect: "/",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Navigation guard for protected routes
|
||||
router.beforeEach((to) => {
|
||||
if (to.meta["requiresAuth"]) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
// Show loading state while authentication is being checked
|
||||
if (isLoading.value) {
|
||||
// You can return a loading component path or handle loading in the component
|
||||
return true; // Allow navigation, handle loading in component
|
||||
}
|
||||
|
||||
if (!isAuthenticated.value) {
|
||||
return "/"; // Redirect to home page
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
export default router;
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Add Navigation Links and Sign Out Functionality
|
||||
|
||||
Update the navigation component to include links to the sign-in and sign-up pages, and implement the sign-out.
|
||||
|
||||
```vue src/components/Navigation.vue lines highlight={17-22,25-30,38,41-55}
|
||||
<template>
|
||||
<nav class="navigation">
|
||||
<div class="nav-container">
|
||||
<RouterLink to="/" class="nav-logo">
|
||||
Nhost Vue Demo
|
||||
</RouterLink>
|
||||
|
||||
<div class="nav-links">
|
||||
<RouterLink to="/" class="nav-link">
|
||||
Home
|
||||
</RouterLink>
|
||||
|
||||
<template v-if="isAuthenticated">
|
||||
<RouterLink to="/profile" class="nav-link">
|
||||
Profile
|
||||
</RouterLink>
|
||||
<button
|
||||
@click="handleSignOut"
|
||||
class="nav-link nav-button"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<RouterLink to="/signin" class="nav-link">
|
||||
Sign In
|
||||
</RouterLink>
|
||||
<RouterLink to="/signup" class="nav-link">
|
||||
Sign Up
|
||||
</RouterLink>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouterLink, useRouter } from "vue-router";
|
||||
import { useAuth } from "../lib/nhost/auth";
|
||||
|
||||
const { isAuthenticated, session, nhost } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
if (session.value) {
|
||||
await nhost.auth.signOut({
|
||||
refreshToken: session.value.refreshToken,
|
||||
});
|
||||
}
|
||||
router.push("/");
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error("Error signing out:", message);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Run and Test the Application
|
||||
|
||||
Start your development server and test the complete authentication flow to ensure everything works properly.
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
|
||||
Things to try out:
|
||||
|
||||
1. Try signing up with a new email address. Check your email for the verification link and click it. See how you are sent to the verification page and then redirected to your profile.
|
||||
2. Try signing out and then signing back in with the same credentials.
|
||||
3. Notice how navigation links change based on authentication state showing "Sign In" and "Sign Up" when logged out, and "Profile" and "Sign Out" when logged in.
|
||||
4. Check how the homepage also reflects the authentication state with appropriate messages.
|
||||
5. Open multiple tabs and test signing out from one tab to see how other tabs respond. Now sign back in and see the changes propagate across tabs.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Key Features Demonstrated
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Complete Registration Flow" icon="user-plus">
|
||||
Full email/password registration with proper form validation and user feedback.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Email Verification" icon="envelope-circle-check">
|
||||
Custom `/verify` endpoint that securely processes email verification tokens.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Error Handling" icon="triangle-exclamation">
|
||||
Comprehensive error handling for unverified emails, failed authentication, and network issues.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Visual Feedback" icon="eye">
|
||||
Loading states, success messages, and clear error displays throughout the authentication flow.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Session Management" icon="clock">
|
||||
Complete sign out functionality and proper session state management across the application.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
828
docs/getting-started/tutorials/vue/4-graphql-operations.mdx
Normal file
@@ -0,0 +1,828 @@
|
||||
---
|
||||
title: GraphQL Operations in Vue
|
||||
description: Learn how to perform GraphQL operations and manage database permissions while building a complete todos application with Nhost and Vue
|
||||
sidebarTitle: "GraphQL Operations"
|
||||
icon: code
|
||||
---
|
||||
|
||||
This part builds upon the previous parts by demonstrating how to perform GraphQL operations with proper database permissions. You'll learn how to design database tables, configure user permissions, and implement complete CRUD operations through GraphQL queries and mutations in a real todos application.
|
||||
|
||||
<Info>
|
||||
This is **Part 4** in the Full-Stack Vue Development with Nhost series. This part focuses on GraphQL operations, database management, and permission-based data access control in a production application.
|
||||
</Info>
|
||||
|
||||
## Full-Stack Vue Development with Nhost
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/vue/1-introduction">
|
||||
Set up your Nhost project
|
||||
</Card>
|
||||
|
||||
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/vue/2-protected-routes">
|
||||
Route protection basics
|
||||
</Card>
|
||||
|
||||
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/vue/3-user-authentication">
|
||||
Complete auth flow
|
||||
</Card>
|
||||
|
||||
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/vue/4-graphql-operations">
|
||||
**Current** - CRUD operations with GraphQL
|
||||
</Card>
|
||||
|
||||
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/vue/5-file-uploads">
|
||||
File upload and management
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Complete the [User Authentication part](/getting-started/tutorials/vue/3-user-authentication) first
|
||||
- The project from the previous part set up and running
|
||||
|
||||
## What You'll Build
|
||||
|
||||
By the end of this part, you'll have:
|
||||
- **GraphQL queries and mutations** for complete CRUD operations
|
||||
- **Database schema** with proper relationships and constraints
|
||||
- **User permissions** for secure data access control
|
||||
- **Vue components** that interact with GraphQL endpoint
|
||||
|
||||
## Step-by-Step Guide
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
|
||||
### Create the To-Dos Table
|
||||
|
||||
First, we'll perform the database changes to set up the todos table with proper schema and relationships to users.
|
||||
|
||||
In your Nhost project dashboard:
|
||||
1. Navigate to **Database**
|
||||
2. Click on the SQL Editor
|
||||
|
||||
Enter the following SQL:
|
||||
|
||||
<Tabs>
|
||||
|
||||
<Tab title="SQL">
|
||||
|
||||
```sql
|
||||
CREATE TABLE public.todos (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
created_at timestamptz DEFAULT now() NOT NULL,
|
||||
updated_at timestamptz DEFAULT now() NOT NULL,
|
||||
title text NOT NULL,
|
||||
details text,
|
||||
completed bool DEFAULT false NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (user_id) REFERENCES auth.users (id) ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
|
||||
CREATE TRIGGER update_todos_updated_at
|
||||
BEFORE UPDATE ON public.todos
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
```
|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab title="UI">
|
||||

|
||||
</Tab>
|
||||
|
||||
|
||||
</Tabs>
|
||||
|
||||
<Warning>
|
||||
Please make sure to enable **Track this** so that the new table todos is available through the auto-generated APIs
|
||||
</Warning>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Set Up Permissions
|
||||
|
||||
It’s now time to set permission rules for the table you just created. With the table `todos` selected, click on **…**, followed by **Edit Permissions**.
|
||||
You will set permissions for the **user** role and actions **insert**, **select**, **update**, and **delete**.
|
||||
|
||||
<Tabs>
|
||||
|
||||
<Tab title="Insert">
|
||||
|
||||
When inserting permissions we are only allowing users to set the `title`, `details`, and `completed` columns as the rest of the columns are set automatically by the backend. The `user_id` column is configured as a preset to the currently authenticated user's ID using the `X-Hasura-User-Id` session variable. This ensures that each todo is associated with the user who created it.
|
||||
|
||||
|
||||

|
||||
</Tab>
|
||||
|
||||
<Tab title="Select">
|
||||
|
||||
For selecting (reading) todos, we are allowing to read all columns but only for rows where the `user_id` matches the authenticated user's ID. This ensures that users can only see their own todos.
|
||||
|
||||

|
||||
</Tab>
|
||||
|
||||
<Tab title="Update">
|
||||
|
||||
When updating todos, we are allowing users to modify the `title`, `details`, and `completed` columns but only for rows where the `user_id` matches their own ID. This prevents users from modifying todos that do not belong to them.
|
||||
|
||||

|
||||
</Tab>
|
||||
|
||||
<Tab title="Delete">
|
||||
|
||||
For deleting todos, we are allowing users to delete rows only where the `user_id` matches their own ID. This ensures that users cannot delete todos that belong to other users.
|
||||
|
||||

|
||||
</Tab>
|
||||
|
||||
</Tabs>
|
||||
|
||||
</Step>
|
||||
|
||||
|
||||
<Step>
|
||||
|
||||
### Create the Todos Page Component
|
||||
|
||||
Now let's implement the Vue component that uses the database we just configured.
|
||||
|
||||
```vue src/views/Todos.vue lines
|
||||
<template>
|
||||
<div v-if="!session" class="auth-message">
|
||||
<p>Please sign in to view your todos.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="container">
|
||||
<header class="page-header">
|
||||
<h1 class="page-title">
|
||||
My Todos
|
||||
<button
|
||||
v-if="!showAddForm"
|
||||
type="button"
|
||||
@click="showAddForm = true"
|
||||
class="add-todo-btn"
|
||||
title="Add a new todo"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
<strong>Error:</strong> {{ error }}
|
||||
</div>
|
||||
|
||||
<div v-if="showAddForm" class="todo-form-card">
|
||||
<form @submit.prevent="addTodo" class="todo-form">
|
||||
<h2 class="form-title">Add New Todo</h2>
|
||||
<div class="form-fields">
|
||||
<div class="field-group">
|
||||
<label :for="titleId">Title *</label>
|
||||
<input
|
||||
:id="titleId"
|
||||
type="text"
|
||||
v-model="newTodoTitle"
|
||||
placeholder="What needs to be done?"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label :for="detailsId">Details</label>
|
||||
<textarea
|
||||
:id="detailsId"
|
||||
v-model="newTodoDetails"
|
||||
placeholder="Add some details (optional)..."
|
||||
:rows="3"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Add Todo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="cancelAddForm"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div v-if="!showAddForm">
|
||||
<div v-if="loading" class="loading-container">
|
||||
<div class="loading-content">
|
||||
<div class="spinner"></div>
|
||||
<span class="loading-text">Loading todos...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="todos-list">
|
||||
<div v-if="todos.length === 0" class="empty-state">
|
||||
<svg
|
||||
class="empty-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
:stroke-width="1.5"
|
||||
d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="empty-title">No todos yet</h3>
|
||||
<p class="empty-description">
|
||||
Create your first todo to get started!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
v-for="todo in todos"
|
||||
:key="todo.id"
|
||||
:class="['todo-card', { completed: todo.completed }]"
|
||||
>
|
||||
<div v-if="editingTodo?.id === todo.id" class="todo-edit">
|
||||
<div class="edit-fields">
|
||||
<div class="field-group">
|
||||
<label :for="`${titleId}-edit`">Title</label>
|
||||
<input
|
||||
:id="`${titleId}-edit`"
|
||||
type="text"
|
||||
v-model="editingTodo.title"
|
||||
/>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label :for="`${detailsId}-edit`">Details</label>
|
||||
<textarea
|
||||
:id="`${detailsId}-edit`"
|
||||
v-model="editingTodo.details"
|
||||
:rows="3"
|
||||
/>
|
||||
</div>
|
||||
<div class="edit-actions">
|
||||
<button
|
||||
type="button"
|
||||
@click="saveEdit"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
✓ Save Changes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="editingTodo = null"
|
||||
class="btn btn-cancel"
|
||||
>
|
||||
✕ Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="todo-content">
|
||||
<div class="todo-header">
|
||||
<button
|
||||
type="button"
|
||||
:class="['todo-title-btn', { completed: todo.completed }]"
|
||||
@click="toggleTodoExpansion(todo.id)"
|
||||
>
|
||||
{{ todo.title }}
|
||||
</button>
|
||||
<div class="todo-actions">
|
||||
<button
|
||||
type="button"
|
||||
@click="toggleComplete(todo)"
|
||||
class="action-btn action-btn-complete"
|
||||
:title="todo.completed ? 'Mark as incomplete' : 'Mark as complete'"
|
||||
>
|
||||
{{ todo.completed ? "↶" : "✓" }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="editingTodo = todo"
|
||||
class="action-btn action-btn-edit"
|
||||
title="Edit todo"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="deleteTodo(todo.id)"
|
||||
class="action-btn action-btn-delete"
|
||||
title="Delete todo"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="expandedTodos.has(todo.id)" class="todo-details">
|
||||
<div
|
||||
v-if="todo.details"
|
||||
:class="['todo-description', { completed: todo.completed }]"
|
||||
>
|
||||
<p>{{ todo.details }}</p>
|
||||
</div>
|
||||
|
||||
<div class="todo-meta">
|
||||
<div class="meta-dates">
|
||||
<span class="meta-item">
|
||||
Created: {{ new Date(todo.created_at).toLocaleString() }}
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
Updated: {{ new Date(todo.updated_at).toLocaleString() }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="todo.completed" class="completion-badge">
|
||||
<svg
|
||||
class="completion-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
:stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Completed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, useId } from "vue";
|
||||
import { useAuth } from "../lib/nhost/auth";
|
||||
|
||||
// The interfaces below define the structure of our data
|
||||
// They are not strictly necessary but help with type safety
|
||||
|
||||
// Represents a single todo item
|
||||
interface Todo {
|
||||
id: string;
|
||||
title: string;
|
||||
details: string | null;
|
||||
completed: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
// This matches the GraphQL response structure for fetching todos
|
||||
// Can be used as a generic type on the request method
|
||||
interface GetTodos {
|
||||
todos: Todo[];
|
||||
}
|
||||
|
||||
// This matches the GraphQL response structure for inserting a todo
|
||||
// Can be used as a generic type on the request method
|
||||
interface InsertTodo {
|
||||
insert_todos_one: Todo | null;
|
||||
}
|
||||
|
||||
// This matches the GraphQL response structure for updating a todo
|
||||
// Can be used as a generic type on the request method
|
||||
interface UpdateTodo {
|
||||
update_todos_by_pk: Todo | null;
|
||||
}
|
||||
|
||||
const { nhost, session } = useAuth();
|
||||
|
||||
const todos = ref<Todo[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
const newTodoTitle = ref("");
|
||||
const newTodoDetails = ref("");
|
||||
const editingTodo = ref<Todo | null>(null);
|
||||
const showAddForm = ref(false);
|
||||
const expandedTodos = ref<Set<string>>(new Set());
|
||||
|
||||
const titleId = useId();
|
||||
const detailsId = useId();
|
||||
|
||||
const fetchTodos = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
// Make GraphQL request to fetch todos using Nhost client
|
||||
// The query automatically filters by user_id due to Hasura permissions
|
||||
const response = await nhost.graphql.request<GetTodos>({
|
||||
query: `
|
||||
query GetTodos {
|
||||
todos(order_by: { created_at: desc }) {
|
||||
id
|
||||
title
|
||||
details
|
||||
completed
|
||||
created_at
|
||||
updated_at
|
||||
user_id
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
// Check for GraphQL errors in the response body
|
||||
if (response.body.errors) {
|
||||
throw new Error(
|
||||
response.body.errors[0]?.message || "Failed to fetch todos",
|
||||
);
|
||||
}
|
||||
|
||||
// Extract todos from the GraphQL response data
|
||||
todos.value = response.body?.data?.todos || [];
|
||||
error.value = null;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Failed to fetch todos";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const addTodo = async () => {
|
||||
if (!newTodoTitle.value.trim()) return;
|
||||
|
||||
try {
|
||||
// Execute GraphQL mutation to insert a new todo
|
||||
// user_id is automatically set by Hasura based on JWT token
|
||||
const response = await nhost.graphql.request<InsertTodo>({
|
||||
query: `
|
||||
mutation InsertTodo($title: String!, $details: String) {
|
||||
insert_todos_one(object: { title: $title, details: $details }) {
|
||||
id
|
||||
title
|
||||
details
|
||||
completed
|
||||
created_at
|
||||
updated_at
|
||||
user_id
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
title: newTodoTitle.value.trim(),
|
||||
details: newTodoDetails.value.trim() || null,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.body.errors) {
|
||||
throw new Error(response.body.errors[0]?.message || "Failed to add todo");
|
||||
}
|
||||
|
||||
if (!response.body?.data?.insert_todos_one) {
|
||||
throw new Error("Failed to add todo");
|
||||
}
|
||||
todos.value = [response.body?.data?.insert_todos_one, ...todos.value];
|
||||
newTodoTitle.value = "";
|
||||
newTodoDetails.value = "";
|
||||
showAddForm.value = false;
|
||||
error.value = null;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Failed to add todo";
|
||||
}
|
||||
};
|
||||
|
||||
const updateTodo = async (
|
||||
id: string,
|
||||
updates: Partial<Pick<Todo, "title" | "details" | "completed">>,
|
||||
) => {
|
||||
try {
|
||||
// Execute GraphQL mutation to update an existing todo by primary key
|
||||
// Hasura permissions ensure users can only update their own todos
|
||||
const response = await nhost.graphql.request<UpdateTodo>({
|
||||
query: `
|
||||
mutation UpdateTodo($id: uuid!, $updates: todos_set_input!) {
|
||||
update_todos_by_pk(pk_columns: { id: $id }, _set: $updates) {
|
||||
id
|
||||
title
|
||||
details
|
||||
completed
|
||||
created_at
|
||||
updated_at
|
||||
user_id
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
id,
|
||||
updates,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.body.errors) {
|
||||
throw new Error(
|
||||
response.body.errors[0]?.message || "Failed to update todo",
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.body?.data?.update_todos_by_pk) {
|
||||
throw new Error("Failed to update todo");
|
||||
}
|
||||
|
||||
const updatedTodo = response.body?.data?.update_todos_by_pk;
|
||||
if (updatedTodo) {
|
||||
todos.value = todos.value.map((todo) =>
|
||||
todo.id === id ? updatedTodo : todo,
|
||||
);
|
||||
}
|
||||
editingTodo.value = null;
|
||||
error.value = null;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Failed to update todo";
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTodo = async (id: string) => {
|
||||
if (!confirm("Are you sure you want to delete this todo?")) return;
|
||||
|
||||
try {
|
||||
// Execute GraphQL mutation to delete a todo by primary key
|
||||
// Hasura permissions ensure users can only delete their own todos
|
||||
const response = await nhost.graphql.request({
|
||||
query: `
|
||||
mutation DeleteTodo($id: uuid!) {
|
||||
delete_todos_by_pk(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.body.errors) {
|
||||
throw new Error(
|
||||
response.body.errors[0]?.message || "Failed to delete todo",
|
||||
);
|
||||
}
|
||||
|
||||
todos.value = todos.value.filter((todo) => todo.id !== id);
|
||||
error.value = null;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Failed to delete todo";
|
||||
}
|
||||
};
|
||||
|
||||
const toggleComplete = async (todo: Todo) => {
|
||||
await updateTodo(todo.id, { completed: !todo.completed });
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!editingTodo.value) return;
|
||||
await updateTodo(editingTodo.value.id, {
|
||||
title: editingTodo.value.title,
|
||||
details: editingTodo.value.details,
|
||||
});
|
||||
};
|
||||
|
||||
const toggleTodoExpansion = (todoId: string) => {
|
||||
const newExpanded = new Set(expandedTodos.value);
|
||||
if (newExpanded.has(todoId)) {
|
||||
newExpanded.delete(todoId);
|
||||
} else {
|
||||
newExpanded.add(todoId);
|
||||
}
|
||||
expandedTodos.value = newExpanded;
|
||||
};
|
||||
|
||||
const cancelAddForm = () => {
|
||||
showAddForm.value = false;
|
||||
newTodoTitle.value = "";
|
||||
newTodoDetails.value = "";
|
||||
};
|
||||
|
||||
// Fetch todos when user session is available
|
||||
// The session contains the JWT token needed for GraphQL authentication
|
||||
onMounted(() => {
|
||||
if (session.value) {
|
||||
fetchTodos();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Update Router Configuration
|
||||
|
||||
Add the todos page to your application routing.
|
||||
|
||||
```ts src/router/index.ts lines highlight={7,39-44}
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import { useAuth } from "../lib/nhost/auth";
|
||||
import HomeView from "../views/HomeView.vue";
|
||||
import ProfileView from "../views/ProfileView.vue";
|
||||
import SignIn from "../views/SignIn.vue";
|
||||
import SignUp from "../views/SignUp.vue";
|
||||
import Todos from "../views/Todos.vue";
|
||||
import Verify from "../views/Verify.vue";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: "/",
|
||||
name: "home",
|
||||
component: HomeView,
|
||||
},
|
||||
{
|
||||
path: "/signin",
|
||||
name: "SignIn",
|
||||
component: SignIn,
|
||||
},
|
||||
{
|
||||
path: "/signup",
|
||||
name: "SignUp",
|
||||
component: SignUp,
|
||||
},
|
||||
{
|
||||
path: "/verify",
|
||||
name: "Verify",
|
||||
component: Verify,
|
||||
},
|
||||
{
|
||||
path: "/profile",
|
||||
name: "profile",
|
||||
component: ProfileView,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/todos",
|
||||
name: "Todos",
|
||||
component: Todos,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/:pathMatch(.*)*",
|
||||
redirect: "/",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Navigation guard for protected routes
|
||||
router.beforeEach((to) => {
|
||||
if (to.meta["requiresAuth"]) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
// Show loading state while authentication is being checked
|
||||
if (isLoading.value) {
|
||||
// You can return a loading component path or handle loading in the component
|
||||
return true; // Allow navigation, handle loading in component
|
||||
}
|
||||
|
||||
if (!isAuthenticated.value) {
|
||||
return "/"; // Redirect to home page
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
export default router;
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Update Navigation Links
|
||||
|
||||
Add a link to the todos page in the navigation bar.
|
||||
|
||||
```vue src/components/Navigation.vue lines highlight={14-16}
|
||||
<template>
|
||||
<nav class="navigation">
|
||||
<div class="nav-container">
|
||||
<RouterLink to="/" class="nav-logo">
|
||||
Nhost Vue Demo
|
||||
</RouterLink>
|
||||
|
||||
<div class="nav-links">
|
||||
<RouterLink to="/" class="nav-link">
|
||||
Home
|
||||
</RouterLink>
|
||||
|
||||
<template v-if="isAuthenticated">
|
||||
<RouterLink to="/todos" class="nav-link">
|
||||
Todos
|
||||
</RouterLink>
|
||||
<RouterLink to="/profile" class="nav-link">
|
||||
Profile
|
||||
</RouterLink>
|
||||
<button
|
||||
@click="handleSignOut"
|
||||
class="nav-link nav-button"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<RouterLink to="/signin" class="nav-link">
|
||||
Sign In
|
||||
</RouterLink>
|
||||
<RouterLink to="/signup" class="nav-link">
|
||||
Sign Up
|
||||
</RouterLink>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouterLink, useRouter } from "vue-router";
|
||||
import { useAuth } from "../lib/nhost/auth";
|
||||
|
||||
const { isAuthenticated, session, nhost } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
if (session.value) {
|
||||
await nhost.auth.signOut({
|
||||
refreshToken: session.value.refreshToken,
|
||||
});
|
||||
}
|
||||
router.push("/");
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error("Error signing out:", message);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Test Your Complete Application
|
||||
|
||||
Run your application and test all the functionality:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Things to try out:
|
||||
|
||||
1. Try signing in and out and see how the Todos page is only available when authenticated
|
||||
2. Create, view, edit, complete, and delete todos. See how the UI updates accordingly
|
||||
3. Open the application in another browser or incognito window, sign in with a different account and verify that you cannot see or modify todos from the first account
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Key Features Implemented
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Database Schema" icon="database">
|
||||
Properly designed todos table with constraints, indexes, and automatic timestamp updates for optimal performance.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="GraphQL API" icon="webhook">
|
||||
Auto-generated GraphQL API with queries and mutations for full CRUD operations on todos.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Row-Level Security" icon="shield-check">
|
||||
Comprehensive permissions ensuring users can only access their own todos through all GraphQL operations.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="CRUD Operations" icon="arrows-rotate">
|
||||
Complete Create, Read, Update, Delete functionality with proper error handling and user feedback.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Rich Interface" icon="sparkles">
|
||||
Expandable todo items, inline editing, completion status, and detailed timestamps.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
720
docs/getting-started/tutorials/vue/5-file-uploads.mdx
Normal file
@@ -0,0 +1,720 @@
|
||||
---
|
||||
title: File Uploads in Vue
|
||||
description: Learn how to implement file upload functionality with storage buckets and permissions while building a complete file management system with Nhost and Vue
|
||||
sidebarTitle: "File Uploads"
|
||||
icon: upload
|
||||
---
|
||||
|
||||
This part builds upon the previous GraphQL operations part by demonstrating how to implement file upload functionality with proper storage permissions. You'll learn how to create storage buckets, configure upload permissions, and implement complete file management operations in a Vue application.
|
||||
|
||||
<Info>
|
||||
This is **Part 5** in the Full-Stack Vue Development with Nhost series. This part focuses on file storage, upload operations, and permission-based file access control in a production application.
|
||||
</Info>
|
||||
|
||||
## Full-Stack Vue Development with Nhost
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/vue/1-introduction">
|
||||
Set up your Nhost project
|
||||
</Card>
|
||||
|
||||
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/vue/2-protected-routes">
|
||||
Route protection basics
|
||||
</Card>
|
||||
|
||||
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/vue/3-user-authentication">
|
||||
Complete auth flow
|
||||
</Card>
|
||||
|
||||
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/vue/4-graphql-operations">
|
||||
CRUD operations with GraphQL
|
||||
</Card>
|
||||
|
||||
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/vue/5-file-uploads">
|
||||
**Current** - File upload and management
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Complete the [GraphQL Operations part](/getting-started/tutorials/vue/4-graphql-operations) first
|
||||
- The project from the previous part set up and running
|
||||
|
||||
## What You'll Build
|
||||
|
||||
By the end of this part, you'll have:
|
||||
- A **personal bucket** so users can upload their own private files
|
||||
- **File upload functionality**
|
||||
- **File management interface** for viewing and deleting files
|
||||
- **Security permissions** ensuring users can only access their own files
|
||||
|
||||
## Step-by-Step Guide
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
|
||||
### Create a Personal Storage Bucket
|
||||
|
||||
First, we'll create a storage bucket where users can upload their personal files.
|
||||
|
||||
In your Nhost project dashboard:
|
||||
1. Navigate to **Database**
|
||||
2. Change to **schema.storage**, then buckets
|
||||
3. Now click on `+ Insert` on the top right corner.
|
||||
4. As id set `personal`, leave the rest of the fields blank and click on Insert at the bottom
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Configure Storage Permissions
|
||||
|
||||
Now we need to set up permissions for the storage bucket to ensure the `user` role can only upload, view, and delete their own files.
|
||||
|
||||
<Tabs>
|
||||
|
||||
<Tab title="Upload">
|
||||
|
||||
To upload files we need to grant permissions to insert on the table `storage.files`. Because we want to allow uploading files only to the `personal` bucket we will be using the `bucket_id eq personal` as a custom check. In addition, we are configuring a preset `uploaded_by_user_id = X-Hasura-User-id`, this will automatically extract the user_id from the session and set the column accordingly. Then we can use this in other permissions to allow downloading files and deleting them.
|
||||
|
||||

|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab title="Download">
|
||||
|
||||
To download files users need to be able to query those files. To make sure users can only download files they uploaded we will be leveraging the column `uploaded_by_user_id` column from before and the `bucket_id``.
|
||||
|
||||

|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab title="Delete">
|
||||
|
||||
Similarly to downloading files, to delete files users need to be able to delete rows from the `storage.files` table. Again we will use the `uploaded_by_user_id` column and the `bucket_id` to make sure users can only delete their own files.
|
||||
|
||||

|
||||
|
||||
</Tab>
|
||||
|
||||
</Tabs>
|
||||
|
||||
<Info>
|
||||
You can read more about storage permissions [here](/products/storage/overview#permissions)
|
||||
</Info>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Create the File Upload Component
|
||||
|
||||
Now let's implement the Vue component for file upload functionality.
|
||||
|
||||
```vue src/views/Files.vue lines
|
||||
<template>
|
||||
<div class="container">
|
||||
<header class="page-header">
|
||||
<h1 class="page-title">File Upload</h1>
|
||||
</header>
|
||||
|
||||
<div class="form-card">
|
||||
<h2 class="form-title">Upload a File</h2>
|
||||
|
||||
<div class="field-group">
|
||||
<input
|
||||
type="file"
|
||||
ref="fileInputRef"
|
||||
@change="handleFileChange"
|
||||
style="
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary file-upload-btn"
|
||||
@click="() => fileInputRef?.click()"
|
||||
>
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
role="img"
|
||||
aria-label="Upload file"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
:stroke-width="2"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
<p>Click to select a file</p>
|
||||
<p
|
||||
v-if="selectedFile"
|
||||
class="file-upload-info"
|
||||
>
|
||||
{{ selectedFile.name }} ({{ formatFileSize(selectedFile.size) }})
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<div v-if="uploadResult" class="success-message">
|
||||
File uploaded successfully!
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="handleUpload"
|
||||
:disabled="!selectedFile || uploading"
|
||||
class="btn btn-primary"
|
||||
style="width: 100%"
|
||||
>
|
||||
{{ uploading ? "Uploading..." : "Upload File" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="form-card">
|
||||
<h2 class="form-title">Your Files</h2>
|
||||
|
||||
<div
|
||||
v-if="deleteStatus"
|
||||
:class="deleteStatus.isError ? 'error-message' : 'success-message'"
|
||||
>
|
||||
{{ deleteStatus.message }}
|
||||
</div>
|
||||
|
||||
<div v-if="isFetching" class="loading-container">
|
||||
<div class="loading-content">
|
||||
<div class="spinner"></div>
|
||||
<span class="loading-text">Loading files...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="files.length === 0" class="empty-state">
|
||||
<svg
|
||||
class="empty-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
:stroke-width="1.5"
|
||||
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="empty-title">No files yet</h3>
|
||||
<p class="empty-description">Upload your first file to get started!</p>
|
||||
</div>
|
||||
<div v-else style="overflow-x: auto">
|
||||
<table class="file-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Size</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="file in files" :key="file.id">
|
||||
<td class="file-name">{{ file.name }}</td>
|
||||
<td class="file-meta">{{ file.mimeType }}</td>
|
||||
<td class="file-meta">{{ formatFileSize(file.size || 0) }}</td>
|
||||
<td>
|
||||
<div class="file-actions">
|
||||
<button
|
||||
type="button"
|
||||
@click="
|
||||
() =>
|
||||
handleViewFile(
|
||||
file.id || 'unknown',
|
||||
file.name || 'unknown',
|
||||
file.mimeType || 'unknown',
|
||||
)
|
||||
"
|
||||
:disabled="viewingFile === file.id"
|
||||
class="action-btn action-btn-edit"
|
||||
title="View File"
|
||||
>
|
||||
{{ viewingFile === file.id ? "⏳" : "👁️" }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="() => handleDeleteFile(file.id || 'unknown')"
|
||||
:disabled="deleting === file.id"
|
||||
class="action-btn action-btn-delete"
|
||||
title="Delete File"
|
||||
>
|
||||
{{ deleting === file.id ? "⏳" : "🗑️" }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FileMetadata } from "@nhost/nhost-js/storage";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useAuth } from "../lib/nhost/auth";
|
||||
|
||||
interface DeleteStatus {
|
||||
message: string;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
interface GraphqlGetFilesResponse {
|
||||
files: FileMetadata[];
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
|
||||
const sizes: string[] = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
const i: number = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
|
||||
return `${parseFloat((bytes / 1024 ** i).toFixed(2))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
const { isAuthenticated, nhost } = useAuth();
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||
const selectedFile = ref<File | null>(null);
|
||||
const uploading = ref<boolean>(false);
|
||||
const uploadResult = ref<FileMetadata | null>(null);
|
||||
const isFetching = ref<boolean>(true);
|
||||
const error = ref<string | null>(null);
|
||||
const files = ref<FileMetadata[]>([]);
|
||||
const viewingFile = ref<string | null>(null);
|
||||
const deleting = ref<string | null>(null);
|
||||
const deleteStatus = ref<DeleteStatus | null>(null);
|
||||
|
||||
const fetchFiles = async (): Promise<void> => {
|
||||
isFetching.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
// Use GraphQL to fetch files from the storage system
|
||||
// Files are automatically filtered by user permissions
|
||||
const response = await nhost.graphql.request<GraphqlGetFilesResponse>({
|
||||
query: `query GetFiles {
|
||||
files {
|
||||
id
|
||||
name
|
||||
size
|
||||
mimeType
|
||||
bucketId
|
||||
uploadedByUserId
|
||||
}
|
||||
}`,
|
||||
});
|
||||
|
||||
if (response.body.errors) {
|
||||
throw new Error(
|
||||
response.body.errors[0]?.message || "Failed to fetch files",
|
||||
);
|
||||
}
|
||||
|
||||
files.value = response.body.data?.files || [];
|
||||
} catch (err) {
|
||||
console.error("Error fetching files:", err);
|
||||
error.value = "Failed to load files. Please try refreshing the page.";
|
||||
} finally {
|
||||
isFetching.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (isAuthenticated.value) {
|
||||
fetchFiles();
|
||||
}
|
||||
});
|
||||
|
||||
const handleFileChange = (e: Event): void => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.files && target.files.length > 0) {
|
||||
const file = target.files[0];
|
||||
if (file) {
|
||||
selectedFile.value = file;
|
||||
error.value = null;
|
||||
uploadResult.value = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async (): Promise<void> => {
|
||||
if (!selectedFile.value) {
|
||||
error.value = "Please select a file to upload";
|
||||
return;
|
||||
}
|
||||
|
||||
uploading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
// Upload file to the personal bucket
|
||||
// The uploadedByUserId is automatically set by the storage permissions
|
||||
const response = await nhost.storage.uploadFiles({
|
||||
"bucket-id": "personal",
|
||||
"file[]": [selectedFile.value],
|
||||
});
|
||||
|
||||
const uploadedFile = response.body.processedFiles?.[0];
|
||||
if (uploadedFile === undefined) {
|
||||
throw new Error("Failed to upload file");
|
||||
}
|
||||
uploadResult.value = uploadedFile;
|
||||
|
||||
// Clear the form
|
||||
selectedFile.value = null;
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = "";
|
||||
}
|
||||
|
||||
// Update the files list
|
||||
files.value = [uploadedFile, ...files.value];
|
||||
|
||||
await fetchFiles();
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
uploadResult.value = null;
|
||||
}, 3000);
|
||||
} catch (err: unknown) {
|
||||
const message = (err as Error).message || "An unknown error occurred";
|
||||
error.value = `Failed to upload file: ${message}`;
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewFile = async (
|
||||
fileId: string,
|
||||
fileName: string,
|
||||
mimeType: string,
|
||||
): Promise<void> => {
|
||||
viewingFile.value = fileId;
|
||||
|
||||
try {
|
||||
// Get the file from storage
|
||||
const response = await nhost.storage.getFile(fileId);
|
||||
|
||||
const url = URL.createObjectURL(response.body);
|
||||
|
||||
// Handle different file types appropriately
|
||||
if (
|
||||
mimeType.startsWith("image/") ||
|
||||
mimeType === "application/pdf" ||
|
||||
mimeType.startsWith("text/") ||
|
||||
mimeType.startsWith("video/") ||
|
||||
mimeType.startsWith("audio/")
|
||||
) {
|
||||
// Open viewable files in new tab
|
||||
window.open(url, "_blank");
|
||||
} else {
|
||||
// Download other file types
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// Show download confirmation
|
||||
const newWindow = window.open("", "_blank", "width=400,height=200");
|
||||
if (newWindow) {
|
||||
newWindow.document.documentElement.innerHTML = `
|
||||
<head>
|
||||
<title>File Download</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; padding: 20px; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h3>Downloading: ${fileName}</h3>
|
||||
<p>Your download has started. You can close this window.</p>
|
||||
</body>
|
||||
`;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const message = (err as Error).message || "An unknown error occurred";
|
||||
error.value = `Failed to view file: ${message}`;
|
||||
console.error("Error viewing file:", err);
|
||||
} finally {
|
||||
viewingFile.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFile = async (fileId: string): Promise<void> => {
|
||||
if (!fileId || deleting.value) return;
|
||||
|
||||
deleting.value = fileId;
|
||||
error.value = null;
|
||||
deleteStatus.value = null;
|
||||
|
||||
const fileToDelete = files.value.find((file) => file.id === fileId);
|
||||
const fileName = fileToDelete?.name || "File";
|
||||
|
||||
try {
|
||||
// Delete file from storage
|
||||
// Permissions ensure users can only delete their own files
|
||||
await nhost.storage.deleteFile(fileId);
|
||||
|
||||
deleteStatus.value = {
|
||||
message: `${fileName} deleted successfully`,
|
||||
isError: false,
|
||||
};
|
||||
|
||||
// Remove from local state
|
||||
files.value = files.value.filter((file) => file.id !== fileId);
|
||||
|
||||
await fetchFiles();
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
deleteStatus.value = null;
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
const message = (err as Error).message || "An unknown error occurred";
|
||||
deleteStatus.value = {
|
||||
message: `Failed to delete ${fileName}: ${message}`,
|
||||
isError: true,
|
||||
};
|
||||
console.error("Error deleting file:", err);
|
||||
} finally {
|
||||
deleting.value = null;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Update Router Configuration
|
||||
|
||||
Add the files page to your application routing.
|
||||
|
||||
```ts src/router/index.ts lines highlight={8,46-51}
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import { useAuth } from "../lib/nhost/auth";
|
||||
import Files from "../views/Files.vue";
|
||||
import HomeView from "../views/HomeView.vue";
|
||||
import ProfileView from "../views/ProfileView.vue";
|
||||
import SignIn from "../views/SignIn.vue";
|
||||
import SignUp from "../views/SignUp.vue";
|
||||
import Todos from "../views/Todos.vue";
|
||||
import Verify from "../views/Verify.vue";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: "/",
|
||||
name: "home",
|
||||
component: HomeView,
|
||||
},
|
||||
{
|
||||
path: "/signin",
|
||||
name: "SignIn",
|
||||
component: SignIn,
|
||||
},
|
||||
{
|
||||
path: "/signup",
|
||||
name: "SignUp",
|
||||
component: SignUp,
|
||||
},
|
||||
{
|
||||
path: "/verify",
|
||||
name: "Verify",
|
||||
component: Verify,
|
||||
},
|
||||
{
|
||||
path: "/profile",
|
||||
name: "profile",
|
||||
component: ProfileView,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/todos",
|
||||
name: "Todos",
|
||||
component: Todos,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/files",
|
||||
name: "Files",
|
||||
component: Files,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/:pathMatch(.*)*",
|
||||
redirect: "/",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Navigation guard for protected routes
|
||||
router.beforeEach((to) => {
|
||||
if (to.meta["requiresAuth"]) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
// Show loading state while authentication is being checked
|
||||
if (isLoading.value) {
|
||||
// You can return a loading component path or handle loading in the component
|
||||
return true; // Allow navigation, handle loading in component
|
||||
}
|
||||
|
||||
if (!isAuthenticated.value) {
|
||||
return "/"; // Redirect to home page
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
export default router;
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Update Navigation Links
|
||||
|
||||
Add a link to the files page in the navigation bar.
|
||||
|
||||
```vue src/components/Navigation.vue lines highlight={17-19}
|
||||
<template>
|
||||
<nav class="navigation">
|
||||
<div class="nav-container">
|
||||
<RouterLink to="/" class="nav-logo">
|
||||
Nhost Vue Demo
|
||||
</RouterLink>
|
||||
|
||||
<div class="nav-links">
|
||||
<RouterLink to="/" class="nav-link">
|
||||
Home
|
||||
</RouterLink>
|
||||
|
||||
<template v-if="isAuthenticated">
|
||||
<RouterLink to="/todos" class="nav-link">
|
||||
Todos
|
||||
</RouterLink>
|
||||
<RouterLink to="/files" class="nav-link">
|
||||
Files
|
||||
</RouterLink>
|
||||
<RouterLink to="/profile" class="nav-link">
|
||||
Profile
|
||||
</RouterLink>
|
||||
<button
|
||||
@click="handleSignOut"
|
||||
class="nav-link nav-button"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<RouterLink to="/signin" class="nav-link">
|
||||
Sign In
|
||||
</RouterLink>
|
||||
<RouterLink to="/signup" class="nav-link">
|
||||
Sign Up
|
||||
</RouterLink>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouterLink, useRouter } from "vue-router";
|
||||
import { useAuth } from "../lib/nhost/auth";
|
||||
|
||||
const { isAuthenticated, session, nhost } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
if (session.value) {
|
||||
await nhost.auth.signOut({
|
||||
refreshToken: session.value.refreshToken,
|
||||
});
|
||||
}
|
||||
router.push("/");
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error("Error signing out:", message);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Test Your File Upload System
|
||||
|
||||
Run your application and test all the functionality:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Things to try out:
|
||||
|
||||
1. Try signing in and out and see how the file upload page is only accessible when signed in.
|
||||
2. Upload different types of files (images, documents, etc.)
|
||||
3. View and delete files
|
||||
4. Sign in with another account and verify you cannot see files from the first account
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Key Features Implemented
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Storage Bucket" icon="bucket">
|
||||
Dedicated personal storage bucket with proper configuration for user file isolation.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="File Upload Interface" icon="upload">
|
||||
User-friendly upload interface with file selection, preview, and progress feedback.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="File Management" icon="folder">
|
||||
Complete file listing with metadata, viewing capabilities, and deletion functionality.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="File Type Handling" icon="file">
|
||||
Intelligent handling of different file types with appropriate viewing/download behavior.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Error Handling" icon="triangle-exclamation">
|
||||
Comprehensive error handling with user-friendly messages for upload and management operations.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
BIN
docs/images/tutorials/create-nhost-project/1.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
docs/images/tutorials/create-nhost-project/2.png
Normal file
|
After Width: | Height: | Size: 625 KiB |
BIN
docs/images/tutorials/create-nhost-project/3.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
docs/images/tutorials/todos/1.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
docs/images/tutorials/todos/2.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
docs/images/tutorials/todos/3.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
docs/images/tutorials/todos/4.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
docs/images/tutorials/todos/5.png
Normal file
|
After Width: | Height: | Size: 1018 KiB |
BIN
docs/images/tutorials/uploads/1.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
docs/images/tutorials/uploads/2.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
docs/images/tutorials/uploads/3.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
docs/images/tutorials/uploads/4.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
@@ -1,3 +1,4 @@
|
||||
import * as Linking from "expo-linking";
|
||||
import { Link, router, useLocalSearchParams } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
@@ -25,6 +26,7 @@ export default function SignUp() {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [appleAuthInProgress, setAppleAuthInProgress] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<boolean>(false);
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"password" | "magic" | "social" | "native"
|
||||
>("password");
|
||||
@@ -41,6 +43,7 @@ export default function SignUp() {
|
||||
const handleSubmit = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
try {
|
||||
const response = await nhost.auth.signUpEmailPassword({
|
||||
@@ -48,6 +51,7 @@ export default function SignUp() {
|
||||
password,
|
||||
options: {
|
||||
displayName,
|
||||
redirectTo: Linking.createURL("verify"),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -55,8 +59,8 @@ export default function SignUp() {
|
||||
// Successfully signed up and automatically signed in
|
||||
router.replace("/profile");
|
||||
} else {
|
||||
// Verification email might be required
|
||||
router.replace("/signin");
|
||||
// Verification email sent
|
||||
setSuccess(true);
|
||||
}
|
||||
} catch (err) {
|
||||
const errMessage =
|
||||
@@ -78,157 +82,185 @@ export default function SignUp() {
|
||||
<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>
|
||||
{success ? (
|
||||
<>
|
||||
<Text style={styles.cardTitle}>Check Your Email</Text>
|
||||
<View style={styles.messageContainer}>
|
||||
<View style={styles.successMessageBox}>
|
||||
<Text style={styles.successText}>
|
||||
We've sent a verification link to{" "}
|
||||
<Text style={styles.emailText}>{email}</Text>
|
||||
</Text>
|
||||
<Text style={styles.successText}>
|
||||
Please check your email and click the verification link to
|
||||
activate your account.
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={() => router.replace("/signin")}
|
||||
>
|
||||
<Text style={styles.buttonText}>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 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>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.tabButton,
|
||||
activeTab === "magic" && styles.activeTab,
|
||||
]}
|
||||
onPress={() => setActiveTab("magic")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "magic" && styles.activeTabText,
|
||||
]}
|
||||
<TouchableOpacity
|
||||
style={styles.secondaryButton}
|
||||
onPress={() => router.setParams({ magic: "" })}
|
||||
>
|
||||
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>}
|
||||
|
||||
<Text style={styles.secondaryButtonText}>
|
||||
Back to sign up
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.tabContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={handleSubmit}
|
||||
disabled={isLoading}
|
||||
style={[
|
||||
styles.tabButton,
|
||||
activeTab === "password" && styles.activeTab,
|
||||
]}
|
||||
onPress={() => setActiveTab("password")}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Sign Up</Text>
|
||||
)}
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "password" && styles.activeTabText,
|
||||
]}
|
||||
>
|
||||
Password
|
||||
</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>
|
||||
<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>
|
||||
@@ -343,6 +375,18 @@ const styles = StyleSheet.create({
|
||||
textAlign: "center",
|
||||
marginBottom: 15,
|
||||
},
|
||||
successMessageBox: {
|
||||
backgroundColor: "#f0fff4",
|
||||
borderColor: "#38a169",
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
marginBottom: 20,
|
||||
},
|
||||
emailText: {
|
||||
fontWeight: "bold",
|
||||
color: "#2d3748",
|
||||
},
|
||||
messageContainer: {
|
||||
alignItems: "center",
|
||||
paddingVertical: 10,
|
||||
|
||||
@@ -35,14 +35,13 @@ select_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
columns:
|
||||
- credential_id
|
||||
- id
|
||||
- nickname
|
||||
- user_id
|
||||
- credential_id
|
||||
- nickname
|
||||
filter:
|
||||
user_id:
|
||||
_eq: X-Hasura-User-Id
|
||||
comment: ""
|
||||
update_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
@@ -52,11 +51,3 @@ update_permissions:
|
||||
user_id:
|
||||
_eq: X-Hasura-User-Id
|
||||
check: null
|
||||
comment: ""
|
||||
delete_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
filter:
|
||||
user_id:
|
||||
_eq: X-Hasura-User-Id
|
||||
comment: ""
|
||||
|
||||
@@ -120,27 +120,3 @@ array_relationships:
|
||||
table:
|
||||
name: user_providers
|
||||
schema: auth
|
||||
select_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
columns:
|
||||
- active_mfa_type
|
||||
- display_name
|
||||
- email
|
||||
- id
|
||||
- metadata
|
||||
filter:
|
||||
id:
|
||||
_eq: X-Hasura-User-Id
|
||||
comment: ""
|
||||
update_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
columns:
|
||||
- display_name
|
||||
- metadata
|
||||
filter:
|
||||
id:
|
||||
_eq: X-Hasura-User-Id
|
||||
check: null
|
||||
comment: ""
|
||||
|
||||
@@ -55,60 +55,41 @@ object_relationships:
|
||||
insert_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
check: {}
|
||||
check:
|
||||
bucket_id:
|
||||
_eq: personal
|
||||
set:
|
||||
uploaded_by_user_id: x-hasura-User-Id
|
||||
uploaded_by_user_id: X-Hasura-User-Id
|
||||
columns:
|
||||
- bucket_id
|
||||
- id
|
||||
- mime_type
|
||||
- bucket_id
|
||||
- name
|
||||
- size
|
||||
- uploaded_by_user_id
|
||||
comment: ""
|
||||
- mime_type
|
||||
select_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
columns:
|
||||
- is_uploaded
|
||||
- size
|
||||
- metadata
|
||||
- bucket_id
|
||||
- etag
|
||||
- mime_type
|
||||
- name
|
||||
- id
|
||||
- created_at
|
||||
- updated_at
|
||||
- id
|
||||
- uploaded_by_user_id
|
||||
filter:
|
||||
uploaded_by_user_id:
|
||||
_eq: X-Hasura-User-Id
|
||||
comment: ""
|
||||
update_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
columns:
|
||||
- is_uploaded
|
||||
- size
|
||||
- metadata
|
||||
- bucket_id
|
||||
- etag
|
||||
- mime_type
|
||||
- name
|
||||
- created_at
|
||||
- updated_at
|
||||
- id
|
||||
- size
|
||||
- mime_type
|
||||
- etag
|
||||
- is_uploaded
|
||||
- uploaded_by_user_id
|
||||
- metadata
|
||||
filter:
|
||||
uploaded_by_user_id:
|
||||
_eq: X-Hasura-User-Id
|
||||
check: {}
|
||||
comment: ""
|
||||
_and:
|
||||
- bucket_id:
|
||||
_eq: personal
|
||||
- uploaded_by_user_id:
|
||||
_eq: X-Hasura-User-Id
|
||||
delete_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
filter:
|
||||
uploaded_by_user_id:
|
||||
_eq: X-Hasura-User-Id
|
||||
comment: ""
|
||||
|
||||
@@ -7,11 +7,7 @@
|
||||
- "!include auth_user_roles.yaml"
|
||||
- "!include auth_user_security_keys.yaml"
|
||||
- "!include auth_users.yaml"
|
||||
- "!include public_attachments.yaml"
|
||||
- "!include public_comments.yaml"
|
||||
- "!include public_movies.yaml"
|
||||
- "!include public_ninja_turtles.yaml"
|
||||
- "!include public_tasks.yaml"
|
||||
- "!include public_todos.yaml"
|
||||
- "!include storage_buckets.yaml"
|
||||
- "!include storage_files.yaml"
|
||||
- "!include storage_virus.yaml"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
-- Could not auto-generate a down migration.
|
||||
@@ -0,0 +1,26 @@
|
||||
CREATE TABLE public.todos (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
created_at timestamptz DEFAULT now() NOT NULL,
|
||||
updated_at timestamptz DEFAULT now() NOT NULL,
|
||||
title text NOT NULL,
|
||||
details text,
|
||||
completed bool DEFAULT false NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (user_id) REFERENCES auth.users (id) ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
|
||||
CREATE TRIGGER update_todos_updated_at
|
||||
BEFORE UPDATE ON public.todos
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
@@ -1,7 +1,7 @@
|
||||
[global]
|
||||
|
||||
[hasura]
|
||||
version = 'v2.46.0-ce'
|
||||
version = 'v2.48.5-ce'
|
||||
adminSecret = '{{ secrets.HASURA_GRAPHQL_ADMIN_SECRET }}'
|
||||
webhookSecret = '{{ secrets.NHOST_WEBHOOK_SECRET }}'
|
||||
|
||||
@@ -28,7 +28,7 @@ httpPoolSize = 100
|
||||
|
||||
[functions]
|
||||
[functions.node]
|
||||
version = 20
|
||||
version = 22
|
||||
|
||||
[auth]
|
||||
version = '0.41.1'
|
||||
@@ -64,7 +64,7 @@ rating = 'g'
|
||||
|
||||
[auth.session]
|
||||
[auth.session.accessToken]
|
||||
expiresIn = 65
|
||||
expiresIn = 900
|
||||
|
||||
[auth.session.refreshToken]
|
||||
expiresIn = 2592000
|
||||
@@ -82,7 +82,7 @@ enabled = false
|
||||
|
||||
[auth.method.emailPassword]
|
||||
hibpEnabled = false
|
||||
emailVerificationRequired = false
|
||||
emailVerificationRequired = true
|
||||
passwordMinLength = 9
|
||||
|
||||
[auth.method.smsPasswordless]
|
||||
|
||||
@@ -25,12 +25,17 @@ export async function signUp(formData: FormData) {
|
||||
// Get the server Nhost client
|
||||
const nhost = await createNhostClient();
|
||||
|
||||
// Get origin for redirect URL
|
||||
const origin =
|
||||
process.env["NEXT_PUBLIC_APP_URL"] || "http://localhost:3000";
|
||||
|
||||
// Sign up with email and password
|
||||
const response = await nhost.auth.signUpEmailPassword({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
displayName,
|
||||
redirectTo: `${origin}/verify`,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -43,6 +48,13 @@ export async function signUp(formData: FormData) {
|
||||
return { redirect: "/profile" };
|
||||
}
|
||||
|
||||
// If no session but no error, email verification was sent
|
||||
if (response.body) {
|
||||
return {
|
||||
redirect: `/signup?verify=success&email=${encodeURIComponent(email)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// If we got here, something went wrong
|
||||
return { error: "Failed to sign up" };
|
||||
} catch (err) {
|
||||
|
||||
@@ -9,51 +9,82 @@ import SignUpForm from "./SignUpForm";
|
||||
export default async function SignUp({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ error?: string; magic?: string }>;
|
||||
searchParams: Promise<{
|
||||
error?: string;
|
||||
magic?: string;
|
||||
verify?: string;
|
||||
email?: string;
|
||||
}>;
|
||||
}) {
|
||||
// Extract error and magic link status from URL
|
||||
const params = await searchParams;
|
||||
const error = params?.error;
|
||||
const magicLinkSent = params?.magic === "success";
|
||||
const verificationSent = params?.verify === "success";
|
||||
const email = params?.email;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<h1 className="text-3xl mb-6 gradient-text">Nhost SDK Demo</h1>
|
||||
|
||||
<div className="glass-card w-full p-8 mb-6">
|
||||
<h2 className="text-2xl mb-6">Sign Up</h2>
|
||||
{verificationSent ? (
|
||||
<>
|
||||
<h2 className="text-2xl mb-6">Check Your Email</h2>
|
||||
|
||||
{magicLinkSent ? (
|
||||
<div className="text-center">
|
||||
<p className="mb-4">
|
||||
Magic link sent! Check your email to sign in.
|
||||
</p>
|
||||
<Link href="/signup" className="btn btn-secondary">
|
||||
Back to sign up
|
||||
</Link>
|
||||
</div>
|
||||
<div className="text-center py-4">
|
||||
<div className="mb-4 p-4 bg-green-100 text-green-700 rounded-md">
|
||||
<p className="mb-2">
|
||||
We've sent a verification link to <strong>{email}</strong>
|
||||
</p>
|
||||
<p>
|
||||
Please check your email and click the verification link to
|
||||
activate your account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link href="/signin" className="btn btn-primary">
|
||||
Back to Sign In
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<TabForm
|
||||
passwordTabContent={<SignUpForm initialError={error} />}
|
||||
magicTabContent={
|
||||
<div>
|
||||
<MagicLinkForm
|
||||
sendMagicLinkAction={sendMagicLink}
|
||||
showDisplayName
|
||||
buttonLabel="Sign up with Magic Link"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
socialTabContent={
|
||||
<>
|
||||
<h2 className="text-2xl mb-6">Sign Up</h2>
|
||||
|
||||
{magicLinkSent ? (
|
||||
<div className="text-center">
|
||||
<p className="mb-6">Sign up using your Social account</p>
|
||||
<SocialSignIn provider="github" />
|
||||
<p className="mb-4">
|
||||
Magic link sent! Check your email to sign in.
|
||||
</p>
|
||||
<Link href="/signup" className="btn btn-secondary">
|
||||
Back to sign up
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
webauthnTabContent={
|
||||
<WebAuthnSignUpForm buttonLabel="Sign up with Security Key" />
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<TabForm
|
||||
passwordTabContent={<SignUpForm initialError={error} />}
|
||||
magicTabContent={
|
||||
<div>
|
||||
<MagicLinkForm
|
||||
sendMagicLinkAction={sendMagicLink}
|
||||
showDisplayName
|
||||
buttonLabel="Sign up with Magic Link"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
socialTabContent={
|
||||
<div className="text-center">
|
||||
<p className="mb-6">Sign up using your Social account</p>
|
||||
<SocialSignIn provider="github" />
|
||||
</div>
|
||||
}
|
||||
webauthnTabContent={
|
||||
<WebAuthnSignUpForm buttonLabel="Sign up with Security Key" />
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -17,14 +17,16 @@ export async function middleware(request: NextRequest) {
|
||||
(route) => path === route || path.startsWith(`${route}/`),
|
||||
);
|
||||
|
||||
// this is the only Nhost specific code in this middleware
|
||||
// we call the Nhost middleware even on "public" routes
|
||||
// to refresh the session if needed
|
||||
const session = await handleNhostMiddleware(request, response);
|
||||
|
||||
// If it's a public route, allow access without checking auth
|
||||
if (isPublicRoute) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// this is the only Nhost specific code in this middleware
|
||||
const session = await handleNhostMiddleware(request, response);
|
||||
|
||||
// If no session and not a public route, redirect to signin
|
||||
if (!session) {
|
||||
const signInUrl = new URL("/signin", request.url);
|
||||
|
||||
@@ -14,6 +14,7 @@ import Home from "./pages/Home";
|
||||
import Profile from "./pages/Profile";
|
||||
import SignIn from "./pages/SignIn";
|
||||
import SignUp from "./pages/SignUp";
|
||||
import Todos from "./pages/Todos";
|
||||
import Upload from "./pages/Upload";
|
||||
import Verify from "./pages/Verify";
|
||||
|
||||
@@ -59,6 +60,7 @@ const router = createBrowserRouter(
|
||||
<Route path="verify" element={<Verify />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="profile" element={<Profile />} />
|
||||
<Route path="todos" element={<Todos />} />
|
||||
<Route path="upload" element={<Upload />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
|
||||
@@ -25,6 +25,9 @@ export default function Navigation(): JSX.Element {
|
||||
>
|
||||
Profile
|
||||
</Link>
|
||||
<Link to="/todos" className={`nav-link ${isActive("/todos")}`}>
|
||||
Todos
|
||||
</Link>
|
||||
<Link
|
||||
to="/upload"
|
||||
className={`nav-link ${isActive("/upload")}`}
|
||||
|
||||
@@ -554,3 +554,203 @@ pre {
|
||||
.tab-content {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
/* Additional utility classes */
|
||||
.w-4 {
|
||||
width: 1rem;
|
||||
}
|
||||
.w-5 {
|
||||
width: 1.25rem;
|
||||
}
|
||||
.w-6 {
|
||||
width: 1.5rem;
|
||||
}
|
||||
.w-16 {
|
||||
width: 4rem;
|
||||
}
|
||||
.h-4 {
|
||||
height: 1rem;
|
||||
}
|
||||
.h-5 {
|
||||
height: 1.25rem;
|
||||
}
|
||||
.h-6 {
|
||||
height: 1.5rem;
|
||||
}
|
||||
.h-16 {
|
||||
height: 4rem;
|
||||
}
|
||||
.mr-2 {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
.mb-2 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.mb-3 {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.pt-3 {
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
.p-3 {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.space-x-1 > * + * {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
.space-x-2 > * + * {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
.space-x-3 > * + * {
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
.space-y-4 > * + * {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
.inline-block {
|
||||
display: inline-block;
|
||||
}
|
||||
.leading-relaxed {
|
||||
line-height: 1.625;
|
||||
}
|
||||
.rounded {
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
.rounded-full {
|
||||
border-radius: 9999px;
|
||||
}
|
||||
.border {
|
||||
border-width: 1px;
|
||||
}
|
||||
.border-2 {
|
||||
border-width: 2px;
|
||||
}
|
||||
.border-t {
|
||||
border-top-width: 1px;
|
||||
}
|
||||
.border-t-transparent {
|
||||
border-top-color: transparent;
|
||||
}
|
||||
.opacity-75 {
|
||||
opacity: 0.75;
|
||||
}
|
||||
.line-through {
|
||||
text-decoration-line: line-through;
|
||||
}
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
.transition-all {
|
||||
transition-property: all;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
.duration-200 {
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
.hover\:shadow-lg:hover {
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.hover\:border-secondary:hover {
|
||||
border-color: var(--secondary);
|
||||
}
|
||||
|
||||
/* Color utilities using CSS variables */
|
||||
.text-primary {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.text-secondary {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.text-muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.bg-card-bg {
|
||||
background-color: var(--card-bg);
|
||||
}
|
||||
.border-border-color {
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
.bg-secondary {
|
||||
background-color: var(--secondary);
|
||||
}
|
||||
.border-secondary {
|
||||
border-color: var(--secondary);
|
||||
}
|
||||
.border-primary {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
/* Spinning animation */
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* Floating Add Button */
|
||||
.add-todo-btn {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.add-todo-btn:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 6px 16px rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
|
||||
/* Small Add Button */
|
||||
.add-todo-btn-small {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.2);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.add-todo-btn-small:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
/* Text-only Add Button */
|
||||
.add-todo-text-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.add-todo-text-btn:hover {
|
||||
color: var(--primary-hover);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Todo title hover effect */
|
||||
.hover\:text-primary-hover:hover {
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function SignUp(): JSX.Element {
|
||||
const [displayName, setDisplayName] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const displayNameId = useId();
|
||||
const emailId = useId();
|
||||
const passwordId = useId();
|
||||
@@ -31,6 +32,7 @@ export default function SignUp(): JSX.Element {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
try {
|
||||
const response = await nhost.auth.signUpEmailPassword({
|
||||
@@ -38,15 +40,16 @@ export default function SignUp(): JSX.Element {
|
||||
password,
|
||||
options: {
|
||||
displayName,
|
||||
redirectTo: `${window.location.origin}/verify`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.body) {
|
||||
if (response.body?.session) {
|
||||
// Successfully signed up and automatically signed in
|
||||
navigate("/profile");
|
||||
} else {
|
||||
// Verification email sent
|
||||
navigate("/verify");
|
||||
// Verification email sent or user created but needs verification
|
||||
setSuccess(true);
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err as FetchError<ErrorResponse>;
|
||||
@@ -69,6 +72,38 @@ export default function SignUp(): JSX.Element {
|
||||
window.location.href = url;
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<h1 className="text-3xl mb-6 gradient-text">Nhost SDK Demo</h1>
|
||||
|
||||
<div className="glass-card w-full p-8 mb-6">
|
||||
<h2 className="text-2xl mb-6">Check Your Email</h2>
|
||||
|
||||
<div className="text-center py-4">
|
||||
<div className="mb-4 p-4 bg-green-100 text-green-700 rounded-md">
|
||||
<p className="mb-2">
|
||||
We've sent a verification link to <strong>{email}</strong>
|
||||
</p>
|
||||
<p>
|
||||
Please check your email and click the verification link to
|
||||
activate your account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/signin")}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Back to Sign In
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<h1 className="text-3xl mb-6 gradient-text">Nhost SDK Demo</h1>
|
||||
|
||||
648
examples/demos/react-demo/src/pages/Todos.tsx
Normal file
@@ -0,0 +1,648 @@
|
||||
import type { JSX } from "react";
|
||||
import { useCallback, useEffect, useId, useState } from "react";
|
||||
import { useAuth } from "../lib/nhost/AuthProvider";
|
||||
|
||||
interface Todo {
|
||||
id: string;
|
||||
title: string;
|
||||
details: string | null;
|
||||
completed: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
interface GetTodos {
|
||||
todos: Todo[];
|
||||
}
|
||||
|
||||
interface InsertTodo {
|
||||
insert_todos_one: Todo | null;
|
||||
}
|
||||
|
||||
interface UpdateTodo {
|
||||
update_todos_by_pk: Todo | null;
|
||||
}
|
||||
|
||||
export default function Todos(): JSX.Element {
|
||||
const { nhost, session } = useAuth();
|
||||
const [todos, setTodos] = useState<Todo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [newTodoTitle, setNewTodoTitle] = useState("");
|
||||
const [newTodoDetails, setNewTodoDetails] = useState("");
|
||||
const [editingTodo, setEditingTodo] = useState<Todo | null>(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [expandedTodos, setExpandedTodos] = useState<Set<string>>(new Set());
|
||||
|
||||
const titleId = useId();
|
||||
const detailsId = useId();
|
||||
|
||||
const fetchTodos = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await nhost.graphql.request<GetTodos>({
|
||||
query: `
|
||||
query GetTodos {
|
||||
todos(order_by: { created_at: desc }) {
|
||||
id
|
||||
title
|
||||
details
|
||||
completed
|
||||
created_at
|
||||
updated_at
|
||||
user_id
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
if (response.body.errors) {
|
||||
throw new Error(
|
||||
response.body.errors[0]?.message || "Failed to fetch todos",
|
||||
);
|
||||
}
|
||||
|
||||
setTodos(response.body?.data?.todos || []);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch todos");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [nhost.graphql]);
|
||||
|
||||
const addTodo = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newTodoTitle.trim()) return;
|
||||
|
||||
try {
|
||||
const response = await nhost.graphql.request<InsertTodo>({
|
||||
query: `
|
||||
mutation InsertTodo($title: String!, $details: String) {
|
||||
insert_todos_one(object: { title: $title, details: $details }) {
|
||||
id
|
||||
title
|
||||
details
|
||||
completed
|
||||
created_at
|
||||
updated_at
|
||||
user_id
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
title: newTodoTitle.trim(),
|
||||
details: newTodoDetails.trim() || null,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.body.errors) {
|
||||
throw new Error(
|
||||
response.body.errors[0]?.message || "Failed to add todo",
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.body?.data?.insert_todos_one) {
|
||||
throw new Error("Failed to add todo");
|
||||
}
|
||||
setTodos([response.body?.data?.insert_todos_one, ...todos]);
|
||||
setNewTodoTitle("");
|
||||
setNewTodoDetails("");
|
||||
setShowAddForm(false);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to add todo");
|
||||
}
|
||||
};
|
||||
|
||||
const updateTodo = async (
|
||||
id: string,
|
||||
updates: Partial<Pick<Todo, "title" | "details" | "completed">>,
|
||||
) => {
|
||||
try {
|
||||
const response = await nhost.graphql.request<UpdateTodo>({
|
||||
query: `
|
||||
mutation UpdateTodo($id: uuid!, $updates: todos_set_input!) {
|
||||
update_todos_by_pk(pk_columns: { id: $id }, _set: $updates) {
|
||||
id
|
||||
title
|
||||
details
|
||||
completed
|
||||
created_at
|
||||
updated_at
|
||||
user_id
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
id,
|
||||
updates,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.body.errors) {
|
||||
throw new Error(
|
||||
response.body.errors[0]?.message || "Failed to update todo",
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.body?.data?.update_todos_by_pk) {
|
||||
throw new Error("Failed to update todo");
|
||||
}
|
||||
|
||||
const updatedTodo = response.body?.data?.update_todos_by_pk;
|
||||
if (updatedTodo) {
|
||||
setTodos(todos.map((todo) => (todo.id === id ? updatedTodo : todo)));
|
||||
}
|
||||
setEditingTodo(null);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to update todo");
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTodo = async (id: string) => {
|
||||
if (!confirm("Are you sure you want to delete this todo?")) return;
|
||||
|
||||
try {
|
||||
const response = await nhost.graphql.request({
|
||||
query: `
|
||||
mutation DeleteTodo($id: uuid!) {
|
||||
delete_todos_by_pk(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.body.errors) {
|
||||
throw new Error(
|
||||
response.body.errors[0]?.message || "Failed to delete todo",
|
||||
);
|
||||
}
|
||||
|
||||
setTodos(todos.filter((todo) => todo.id !== id));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to delete todo");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleComplete = async (todo: Todo) => {
|
||||
await updateTodo(todo.id, { completed: !todo.completed });
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!editingTodo) return;
|
||||
await updateTodo(editingTodo.id, {
|
||||
title: editingTodo.title,
|
||||
details: editingTodo.details,
|
||||
});
|
||||
};
|
||||
|
||||
const toggleTodoExpansion = (todoId: string) => {
|
||||
const newExpanded = new Set(expandedTodos);
|
||||
if (newExpanded.has(todoId)) {
|
||||
newExpanded.delete(todoId);
|
||||
} else {
|
||||
newExpanded.add(todoId);
|
||||
}
|
||||
setExpandedTodos(newExpanded);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (session) {
|
||||
fetchTodos();
|
||||
}
|
||||
}, [session, fetchTodos]);
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<p>Please sign in to view your todos.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-3xl font-bold gradient-text">My Todos</h1>
|
||||
{!showAddForm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="add-todo-text-btn"
|
||||
title="Add a new todo"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-error">
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add new todo form */}
|
||||
{showAddForm && (
|
||||
<div className="glass-card mb-8">
|
||||
<form onSubmit={addTodo} className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold">Add New Todo</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowAddForm(false);
|
||||
setNewTodoTitle("");
|
||||
setNewTodoDetails("");
|
||||
}}
|
||||
className="action-icon action-icon-delete"
|
||||
title="Cancel"
|
||||
>
|
||||
<svg
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor={titleId}>Title *</label>
|
||||
<input
|
||||
id={titleId}
|
||||
type="text"
|
||||
value={newTodoTitle}
|
||||
onChange={(e) => setNewTodoTitle(e.target.value)}
|
||||
placeholder="What needs to be done?"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={detailsId}>Details</label>
|
||||
<textarea
|
||||
id={detailsId}
|
||||
value={newTodoDetails}
|
||||
onChange={(e) => setNewTodoDetails(e.target.value)}
|
||||
placeholder="Add some details (optional)..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button type="submit" className="btn btn-primary flex-1">
|
||||
<svg
|
||||
className="w-4 h-4 mr-2 inline-block"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Add Todo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowAddForm(false);
|
||||
setNewTodoTitle("");
|
||||
setNewTodoDetails("");
|
||||
}}
|
||||
className="btn btn-secondary"
|
||||
style={{
|
||||
backgroundColor: "var(--text-muted)",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Todos list */}
|
||||
{!showAddForm &&
|
||||
(loading ? (
|
||||
<div className="loading-container">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin"></div>
|
||||
<span className="text-secondary">Loading todos...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{todos.length === 0 ? (
|
||||
<div className="glass-card p-8 text-center">
|
||||
<svg
|
||||
className="w-16 h-16 mx-auto mb-4 text-muted"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="text-lg font-medium mb-2">No todos yet</h3>
|
||||
<p className="text-muted">
|
||||
Create your first todo to get started!
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
todos.map((todo) => (
|
||||
<div
|
||||
key={todo.id}
|
||||
className={`glass-card transition-all duration-200 ${
|
||||
todo.completed ? "opacity-75" : "hover:shadow-lg"
|
||||
}`}
|
||||
>
|
||||
{editingTodo?.id === todo.id ? (
|
||||
/* Edit mode */
|
||||
<div className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor={`${titleId}-edit`}>Title</label>
|
||||
<input
|
||||
id={`${titleId}-edit`}
|
||||
type="text"
|
||||
value={editingTodo.title}
|
||||
onChange={(e) =>
|
||||
setEditingTodo({
|
||||
...editingTodo,
|
||||
title: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={`${detailsId}-edit`}>Details</label>
|
||||
<textarea
|
||||
id={`${detailsId}-edit`}
|
||||
value={editingTodo.details || ""}
|
||||
onChange={(e) =>
|
||||
setEditingTodo({
|
||||
...editingTodo,
|
||||
details: e.target.value,
|
||||
})
|
||||
}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveEdit}
|
||||
className="btn btn-secondary flex-1"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-2 inline-block"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Save Changes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingTodo(null)}
|
||||
className="btn btn-secondary flex-1"
|
||||
style={{
|
||||
backgroundColor: "var(--text-muted)",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-2 inline-block"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* View mode */
|
||||
<div className="p-6">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`text-xl font-medium transition-all cursor-pointer hover:text-primary-hover text-left ${
|
||||
todo.completed
|
||||
? "line-through text-muted"
|
||||
: "text-primary"
|
||||
}`}
|
||||
onClick={() => toggleTodoExpansion(todo.id)}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{todo.title}
|
||||
</button>
|
||||
<div className="table-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleComplete(todo)}
|
||||
className="action-icon action-icon-view"
|
||||
title={
|
||||
todo.completed
|
||||
? "Mark as incomplete"
|
||||
: "Mark as complete"
|
||||
}
|
||||
>
|
||||
{todo.completed ? (
|
||||
<svg
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingTodo(todo)}
|
||||
className="action-icon action-icon-view"
|
||||
title="Edit todo"
|
||||
>
|
||||
<svg
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteTodo(todo.id)}
|
||||
className="action-icon action-icon-delete"
|
||||
title="Delete todo"
|
||||
>
|
||||
<svg
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedTodos.has(todo.id) && (
|
||||
<div className="mt-4 space-y-3">
|
||||
{todo.details && (
|
||||
<div
|
||||
className={`p-3 rounded bg-card-bg border border-border-color ${
|
||||
todo.completed ? "opacity-75" : ""
|
||||
}`}
|
||||
>
|
||||
<p
|
||||
className={`text-secondary leading-relaxed ${todo.completed ? "line-through" : ""}`}
|
||||
>
|
||||
{todo.details}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="flex items-center space-x-1 text-muted">
|
||||
<span>
|
||||
Created:{" "}
|
||||
{new Date(todo.created_at).toLocaleString()}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-center space-x-1 text-muted">
|
||||
Updated:{" "}
|
||||
<span>
|
||||
{new Date(todo.updated_at).toLocaleString()}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{todo.completed && (
|
||||
<div className="flex items-center space-x-1 text-secondary">
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-xs font-medium">
|
||||
Completed
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ let password = $state("");
|
||||
let displayName = $state("");
|
||||
let isLoading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let success = $state(false);
|
||||
|
||||
let params = $derived(new URLSearchParams($page.url.search));
|
||||
let magicLinkSent = $derived(params.get("magic") === "success");
|
||||
@@ -35,15 +36,16 @@ async function handleSubmit(e: Event) {
|
||||
password,
|
||||
options: {
|
||||
displayName,
|
||||
redirectTo: `${window.location.origin}/verify`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.body) {
|
||||
if (response.body?.session) {
|
||||
// Successfully signed up and automatically signed in
|
||||
void goto("/profile");
|
||||
} else {
|
||||
// Verification email sent
|
||||
void goto("/verify");
|
||||
success = true;
|
||||
}
|
||||
} catch (err) {
|
||||
const fetchError = err as FetchError<ErrorResponse>;
|
||||
@@ -80,14 +82,32 @@ function setDisplayName(newDisplayName: string) {
|
||||
<h1 class="text-3xl mb-6 gradient-text">Nhost SDK Demo</h1>
|
||||
|
||||
<div class="glass-card w-full p-8 mb-6">
|
||||
<h2 class="text-2xl mb-6">Sign Up</h2>
|
||||
{#if success}
|
||||
<h2 class="text-2xl mb-6">Check Your Email</h2>
|
||||
|
||||
{#if magicLinkSent}
|
||||
<div class="text-center">
|
||||
<p class="mb-4">Magic link sent! Check your email to sign up.</p>
|
||||
<a href="/signup" class="btn btn-secondary"> Back to sign up </a>
|
||||
<div class="text-center py-4">
|
||||
<div class="mb-4 p-4 bg-green-100 text-green-700 rounded-md">
|
||||
<p class="mb-2">
|
||||
We've sent a verification link to <strong>{email}</strong>
|
||||
</p>
|
||||
<p>
|
||||
Please check your email and click the verification link to activate your account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a href="/signin" class="btn btn-primary">
|
||||
Back to Sign In
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<h2 class="text-2xl mb-6">Sign Up</h2>
|
||||
|
||||
{#if magicLinkSent}
|
||||
<div class="text-center">
|
||||
<p class="mb-4">Magic link sent! Check your email to sign up.</p>
|
||||
<a href="/signup" class="btn btn-secondary"> Back to sign up </a>
|
||||
</div>
|
||||
{:else}
|
||||
<TabForm>
|
||||
{#snippet passwordTabContent()}
|
||||
<form onsubmit={handleSubmit} class="space-y-5">
|
||||
@@ -166,6 +186,7 @@ function setDisplayName(newDisplayName: string) {
|
||||
/>
|
||||
{/snippet}
|
||||
</TabForm>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -666,3 +666,376 @@ pre {
|
||||
.opacity-75 {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* Todos specific styles */
|
||||
.container {
|
||||
max-width: 42rem;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.auth-message {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background-color: rgba(31, 41, 55, 0.5);
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.875rem;
|
||||
font-weight: bold;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.add-todo-btn {
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 50%;
|
||||
font-size: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.add-todo-btn:hover {
|
||||
background-color: var(--primary-hover);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.5);
|
||||
color: var(--error);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.todo-form-card {
|
||||
background-color: rgba(31, 41, 55, 0.8);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.todo-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.field-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.field-group label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.field-group input,
|
||||
.field-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background-color: rgba(17, 24, 39, 0.8);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.375rem;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.field-group input:focus,
|
||||
.field-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.625rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background-color: rgba(107, 114, 128, 0.2);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background-color: rgba(107, 114, 128, 0.3);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
.loading-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 2px solid var(--border-color);
|
||||
border-top: 2px solid var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.todos-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
margin: 0 auto 1.5rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.todo-card {
|
||||
background-color: rgba(31, 41, 55, 0.8);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.todo-card:hover {
|
||||
border-color: rgba(99, 102, 241, 0.3);
|
||||
box-shadow: 0 0 0 1px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.todo-card.completed {
|
||||
opacity: 0.7;
|
||||
background-color: rgba(34, 197, 94, 0.05);
|
||||
border-color: rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
.todo-edit {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.edit-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.edit-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.todo-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.todo-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.todo-title-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
flex: 1;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.todo-title-btn:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.todo-title-btn.completed {
|
||||
text-decoration: line-through;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.todo-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background-color: rgba(31, 41, 55, 0.8);
|
||||
}
|
||||
|
||||
.action-btn-complete {
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.action-btn-complete:hover {
|
||||
color: var(--secondary-hover);
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
.action-btn-edit:hover {
|
||||
color: var(--primary);
|
||||
background-color: rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.action-btn-delete:hover {
|
||||
color: var(--error);
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.todo-details {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.todo-description {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.todo-description.completed {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.todo-description p {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.todo-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.meta-dates {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.completion-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
background-color: rgba(34, 197, 94, 0.1);
|
||||
color: var(--secondary);
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.completion-icon {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
|
||||
<div class="navbar-links">
|
||||
<template v-if="isAuthenticated">
|
||||
<router-link
|
||||
to="/todos"
|
||||
class="nav-link"
|
||||
:class="{ active: $route.path === '/todos' }"
|
||||
>
|
||||
Todos
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/profile"
|
||||
class="nav-link"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useAuth } from "../lib/nhost/auth";
|
||||
import Profile from "../views/Profile.vue";
|
||||
import SignIn from "../views/SignIn.vue";
|
||||
import SignUp from "../views/SignUp.vue";
|
||||
import Todos from "../views/Todos.vue";
|
||||
import Upload from "../views/Upload.vue";
|
||||
import Verify from "../views/Verify.vue";
|
||||
|
||||
@@ -34,6 +35,12 @@ const router = createRouter({
|
||||
component: Profile,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/todos",
|
||||
name: "Todos",
|
||||
component: Todos,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/upload",
|
||||
name: "Upload",
|
||||
|
||||
@@ -2,7 +2,30 @@
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<h1 class="text-3xl mb-6 gradient-text">Nhost SDK Demo</h1>
|
||||
|
||||
<div class="glass-card w-full p-8 mb-6">
|
||||
<div v-if="success" class="glass-card w-full p-8 mb-6">
|
||||
<h2 class="text-2xl mb-6">Check Your Email</h2>
|
||||
|
||||
<div class="text-center py-4">
|
||||
<div class="mb-4 p-4 bg-green-100 text-green-700 rounded-md">
|
||||
<p class="mb-2">
|
||||
We've sent a verification link to <strong>{{ email }}</strong>
|
||||
</p>
|
||||
<p>
|
||||
Please check your email and click the verification link to activate your account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="router.push('/signin')"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
Back to Sign In
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="glass-card w-full p-8 mb-6">
|
||||
<h2 class="text-2xl mb-6">Sign Up</h2>
|
||||
|
||||
<TabForm>
|
||||
@@ -109,6 +132,7 @@ const password = ref<string>("");
|
||||
const displayName = ref<string>("");
|
||||
const isLoading = ref<boolean>(false);
|
||||
const error = ref<string | null>(null);
|
||||
const success = ref<boolean>(false);
|
||||
|
||||
// If already authenticated, redirect to profile
|
||||
onMounted(() => {
|
||||
@@ -127,15 +151,16 @@ const handleSubmit = async (): Promise<void> => {
|
||||
password: password.value,
|
||||
options: {
|
||||
displayName: displayName.value,
|
||||
redirectTo: `${window.location.origin}/verify`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.body) {
|
||||
if (response.body?.session) {
|
||||
// Successfully signed up and automatically signed in
|
||||
router.push("/profile");
|
||||
} else {
|
||||
// Verification email sent
|
||||
router.push("/verify");
|
||||
success.value = true;
|
||||
}
|
||||
} catch (err) {
|
||||
const errorObj = err as FetchError<ErrorResponse>;
|
||||
|
||||
466
examples/demos/vue-demo/src/views/Todos.vue
Normal file
@@ -0,0 +1,466 @@
|
||||
<template>
|
||||
<div v-if="!session" class="auth-message">
|
||||
<p>Please sign in to view your todos.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="container">
|
||||
<header class="page-header">
|
||||
<h1 class="page-title">
|
||||
My Todos
|
||||
<button
|
||||
v-if="!showAddForm"
|
||||
type="button"
|
||||
@click="showAddForm = true"
|
||||
class="add-todo-btn"
|
||||
title="Add a new todo"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
<strong>Error:</strong> {{ error }}
|
||||
</div>
|
||||
|
||||
<div v-if="showAddForm" class="todo-form-card">
|
||||
<form @submit.prevent="addTodo" class="todo-form">
|
||||
<h2 class="form-title">Add New Todo</h2>
|
||||
<div class="form-fields">
|
||||
<div class="field-group">
|
||||
<label :for="titleId">Title *</label>
|
||||
<input
|
||||
:id="titleId"
|
||||
type="text"
|
||||
v-model="newTodoTitle"
|
||||
placeholder="What needs to be done?"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label :for="detailsId">Details</label>
|
||||
<textarea
|
||||
:id="detailsId"
|
||||
v-model="newTodoDetails"
|
||||
placeholder="Add some details (optional)..."
|
||||
:rows="3"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Add Todo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="cancelAddForm"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div v-if="!showAddForm">
|
||||
<div v-if="loading" class="loading-container">
|
||||
<div class="loading-content">
|
||||
<div class="spinner"></div>
|
||||
<span class="loading-text">Loading todos...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="todos-list">
|
||||
<div v-if="todos.length === 0" class="empty-state">
|
||||
<svg
|
||||
class="empty-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
:stroke-width="1.5"
|
||||
d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="empty-title">No todos yet</h3>
|
||||
<p class="empty-description">
|
||||
Create your first todo to get started!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
v-for="todo in todos"
|
||||
:key="todo.id"
|
||||
:class="['todo-card', { completed: todo.completed }]"
|
||||
>
|
||||
<div v-if="editingTodo?.id === todo.id" class="todo-edit">
|
||||
<div class="edit-fields">
|
||||
<div class="field-group">
|
||||
<label :for="`${titleId}-edit`">Title</label>
|
||||
<input
|
||||
:id="`${titleId}-edit`"
|
||||
type="text"
|
||||
v-model="editingTodo.title"
|
||||
/>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label :for="`${detailsId}-edit`">Details</label>
|
||||
<textarea
|
||||
:id="`${detailsId}-edit`"
|
||||
v-model="editingTodo.details"
|
||||
:rows="3"
|
||||
/>
|
||||
</div>
|
||||
<div class="edit-actions">
|
||||
<button
|
||||
type="button"
|
||||
@click="saveEdit"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
✓ Save Changes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="editingTodo = null"
|
||||
class="btn btn-cancel"
|
||||
>
|
||||
✕ Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="todo-content">
|
||||
<div class="todo-header">
|
||||
<button
|
||||
type="button"
|
||||
:class="['todo-title-btn', { completed: todo.completed }]"
|
||||
@click="toggleTodoExpansion(todo.id)"
|
||||
>
|
||||
{{ todo.title }}
|
||||
</button>
|
||||
<div class="todo-actions">
|
||||
<button
|
||||
type="button"
|
||||
@click="toggleComplete(todo)"
|
||||
class="action-btn action-btn-complete"
|
||||
:title="todo.completed ? 'Mark as incomplete' : 'Mark as complete'"
|
||||
>
|
||||
{{ todo.completed ? "↶" : "✓" }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="editingTodo = todo"
|
||||
class="action-btn action-btn-edit"
|
||||
title="Edit todo"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="deleteTodo(todo.id)"
|
||||
class="action-btn action-btn-delete"
|
||||
title="Delete todo"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="expandedTodos.has(todo.id)" class="todo-details">
|
||||
<div
|
||||
v-if="todo.details"
|
||||
:class="['todo-description', { completed: todo.completed }]"
|
||||
>
|
||||
<p>{{ todo.details }}</p>
|
||||
</div>
|
||||
|
||||
<div class="todo-meta">
|
||||
<div class="meta-dates">
|
||||
<span class="meta-item">
|
||||
Created: {{ new Date(todo.created_at).toLocaleString() }}
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
Updated: {{ new Date(todo.updated_at).toLocaleString() }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="todo.completed" class="completion-badge">
|
||||
<svg
|
||||
class="completion-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
:stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Completed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, useId } from "vue";
|
||||
import { useAuth } from "../lib/nhost/auth";
|
||||
|
||||
// The interfaces below define the structure of our data
|
||||
// They are not strictly necessary but help with type safety
|
||||
|
||||
// Represents a single todo item
|
||||
interface Todo {
|
||||
id: string;
|
||||
title: string;
|
||||
details: string | null;
|
||||
completed: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
// This matches the GraphQL response structure for fetching todos
|
||||
// Can be used as a generic type on the request method
|
||||
interface GetTodos {
|
||||
todos: Todo[];
|
||||
}
|
||||
|
||||
// This matches the GraphQL response structure for inserting a todo
|
||||
// Can be used as a generic type on the request method
|
||||
interface InsertTodo {
|
||||
insert_todos_one: Todo | null;
|
||||
}
|
||||
|
||||
// This matches the GraphQL response structure for updating a todo
|
||||
// Can be used as a generic type on the request method
|
||||
interface UpdateTodo {
|
||||
update_todos_by_pk: Todo | null;
|
||||
}
|
||||
|
||||
const { nhost, session } = useAuth();
|
||||
|
||||
const todos = ref<Todo[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
const newTodoTitle = ref("");
|
||||
const newTodoDetails = ref("");
|
||||
const editingTodo = ref<Todo | null>(null);
|
||||
const showAddForm = ref(false);
|
||||
const expandedTodos = ref<Set<string>>(new Set());
|
||||
|
||||
const titleId = useId();
|
||||
const detailsId = useId();
|
||||
|
||||
const fetchTodos = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
// Make GraphQL request to fetch todos using Nhost client
|
||||
// The query automatically filters by user_id due to Hasura permissions
|
||||
const response = await nhost.graphql.request<GetTodos>({
|
||||
query: `
|
||||
query GetTodos {
|
||||
todos(order_by: { created_at: desc }) {
|
||||
id
|
||||
title
|
||||
details
|
||||
completed
|
||||
created_at
|
||||
updated_at
|
||||
user_id
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
// Check for GraphQL errors in the response body
|
||||
if (response.body.errors) {
|
||||
throw new Error(
|
||||
response.body.errors[0]?.message || "Failed to fetch todos",
|
||||
);
|
||||
}
|
||||
|
||||
// Extract todos from the GraphQL response data
|
||||
todos.value = response.body?.data?.todos || [];
|
||||
error.value = null;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Failed to fetch todos";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const addTodo = async () => {
|
||||
if (!newTodoTitle.value.trim()) return;
|
||||
|
||||
try {
|
||||
// Execute GraphQL mutation to insert a new todo
|
||||
// user_id is automatically set by Hasura based on JWT token
|
||||
const response = await nhost.graphql.request<InsertTodo>({
|
||||
query: `
|
||||
mutation InsertTodo($title: String!, $details: String) {
|
||||
insert_todos_one(object: { title: $title, details: $details }) {
|
||||
id
|
||||
title
|
||||
details
|
||||
completed
|
||||
created_at
|
||||
updated_at
|
||||
user_id
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
title: newTodoTitle.value.trim(),
|
||||
details: newTodoDetails.value.trim() || null,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.body.errors) {
|
||||
throw new Error(response.body.errors[0]?.message || "Failed to add todo");
|
||||
}
|
||||
|
||||
if (!response.body?.data?.insert_todos_one) {
|
||||
throw new Error("Failed to add todo");
|
||||
}
|
||||
todos.value = [response.body?.data?.insert_todos_one, ...todos.value];
|
||||
newTodoTitle.value = "";
|
||||
newTodoDetails.value = "";
|
||||
showAddForm.value = false;
|
||||
error.value = null;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Failed to add todo";
|
||||
}
|
||||
};
|
||||
|
||||
const updateTodo = async (
|
||||
id: string,
|
||||
updates: Partial<Pick<Todo, "title" | "details" | "completed">>,
|
||||
) => {
|
||||
try {
|
||||
// Execute GraphQL mutation to update an existing todo by primary key
|
||||
// Hasura permissions ensure users can only update their own todos
|
||||
const response = await nhost.graphql.request<UpdateTodo>({
|
||||
query: `
|
||||
mutation UpdateTodo($id: uuid!, $updates: todos_set_input!) {
|
||||
update_todos_by_pk(pk_columns: { id: $id }, _set: $updates) {
|
||||
id
|
||||
title
|
||||
details
|
||||
completed
|
||||
created_at
|
||||
updated_at
|
||||
user_id
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
id,
|
||||
updates,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.body.errors) {
|
||||
throw new Error(
|
||||
response.body.errors[0]?.message || "Failed to update todo",
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.body?.data?.update_todos_by_pk) {
|
||||
throw new Error("Failed to update todo");
|
||||
}
|
||||
|
||||
const updatedTodo = response.body?.data?.update_todos_by_pk;
|
||||
if (updatedTodo) {
|
||||
todos.value = todos.value.map((todo) =>
|
||||
todo.id === id ? updatedTodo : todo,
|
||||
);
|
||||
}
|
||||
editingTodo.value = null;
|
||||
error.value = null;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Failed to update todo";
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTodo = async (id: string) => {
|
||||
if (!confirm("Are you sure you want to delete this todo?")) return;
|
||||
|
||||
try {
|
||||
// Execute GraphQL mutation to delete a todo by primary key
|
||||
// Hasura permissions ensure users can only delete their own todos
|
||||
const response = await nhost.graphql.request({
|
||||
query: `
|
||||
mutation DeleteTodo($id: uuid!) {
|
||||
delete_todos_by_pk(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.body.errors) {
|
||||
throw new Error(
|
||||
response.body.errors[0]?.message || "Failed to delete todo",
|
||||
);
|
||||
}
|
||||
|
||||
todos.value = todos.value.filter((todo) => todo.id !== id);
|
||||
error.value = null;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Failed to delete todo";
|
||||
}
|
||||
};
|
||||
|
||||
const toggleComplete = async (todo: Todo) => {
|
||||
await updateTodo(todo.id, { completed: !todo.completed });
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!editingTodo.value) return;
|
||||
await updateTodo(editingTodo.value.id, {
|
||||
title: editingTodo.value.title,
|
||||
details: editingTodo.value.details,
|
||||
});
|
||||
};
|
||||
|
||||
const toggleTodoExpansion = (todoId: string) => {
|
||||
const newExpanded = new Set(expandedTodos.value);
|
||||
if (newExpanded.has(todoId)) {
|
||||
newExpanded.delete(todoId);
|
||||
} else {
|
||||
newExpanded.add(todoId);
|
||||
}
|
||||
expandedTodos.value = newExpanded;
|
||||
};
|
||||
|
||||
const cancelAddForm = () => {
|
||||
showAddForm.value = false;
|
||||
newTodoTitle.value = "";
|
||||
newTodoDetails.value = "";
|
||||
};
|
||||
|
||||
// Fetch todos when user session is available
|
||||
// The session contains the JWT token needed for GraphQL authentication
|
||||
onMounted(() => {
|
||||
if (session.value) {
|
||||
fetchTodos();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -4,12 +4,12 @@ include $(ROOT_DIR)/build/makefiles/general.makefile
|
||||
|
||||
.PHONY: _dev-env-up
|
||||
_dev-env-up:
|
||||
cd ../demos/backend/ && $(ROOT_DIR)/examples/demos/backend/env-up.sh
|
||||
cd ./backend/ && $(ROOT_DIR)/examples/demos/backend/env-up.sh
|
||||
|
||||
|
||||
.PHONY: _dev-env-down
|
||||
_dev-env-down:
|
||||
cd ../demos/backend/ && nhost down --volumes
|
||||
cd ./backend/ && nhost down --volumes
|
||||
|
||||
|
||||
.PHONY: _dev-env-build
|
||||
|
||||
2
examples/guides/backend/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.nhost
|
||||
.secrets
|
||||
16
examples/guides/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/guides/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/guides/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/guides/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/guides/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/guides/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/guides/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/guides/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/guides/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/guides/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í
|
||||