Compare commits
20 Commits
feat/cron-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 22a4c546f7 | |||
| 4258478cab | |||
| 0c7dd25ca2 | |||
| 2da5ddece6 | |||
| c656f0e8f6 | |||
| b8daa5f4b9 | |||
| 298aad6c43 | |||
| ababf6b98b | |||
| f63701baec | |||
| f9d6247807 | |||
| d05d633b71 | |||
|
|
0c63480cc7 | ||
|
|
ab890d8593 | ||
|
|
ee2d9763f7 | ||
|
|
f7ea20db61 | ||
|
|
99ac1aee3a | ||
|
|
bb9aaf2903 | ||
|
|
8e82edd0c6 | ||
|
|
b0256da33f | ||
|
|
351daa5fbe |
@@ -49,4 +49,4 @@ This repository is a monorepo that contains multiple packages and applications.
|
||||
- `tools/codegen` - Internal code generation tool to build the SDK
|
||||
- `tools/mintlify-openapi` - Internal tool to generate reference documentation for Mintlify from an OpenAPI spec.
|
||||
|
||||
For details about those projects and how to contribure, please refer to their respective `README.md` and `CONTRIBUTING.md` files.
|
||||
For details about those projects and how to contribute, please refer to their respective `README.md` and `CONTRIBUTING.md` files.
|
||||
|
||||
@@ -107,6 +107,7 @@ Nhost is frontend agnostic, which means Nhost works with all frontend frameworks
|
||||
# Resources
|
||||
|
||||
- Start developing locally with the [Nhost CLI](https://docs.nhost.io/platform/cli/local-development)
|
||||
|
||||
## Nhost Clients
|
||||
|
||||
- [JavaScript/TypeScript](https://docs.nhost.io/reference/javascript/nhost-js/main)
|
||||
@@ -137,7 +138,7 @@ Here are some ways of contributing to making Nhost better:
|
||||
|
||||
- **[Try out Nhost](https://docs.nhost.io)**, and think of ways to make the service better. Let us know here on GitHub.
|
||||
- Join our [Discord](https://discord.com/invite/9V7Qb2U) and connect with other members to share and learn from.
|
||||
- Send a pull request to any of our [open source repositories](https://github.com/nhost) on Github. Check our [contribution guide](https://github.com/nhost/nhost/blob/main/CONTRIBUTING.md) and our [developers guide](https://github.com/nhost/nhost/blob/main/DEVELOPERS.md) for more details about how to contribute. We're looking forward to your contribution!
|
||||
- Send a pull request to any of our [open source repositories](https://github.com/nhost) on Github. Check out our [contribution guide](https://github.com/nhost/nhost/blob/main/CONTRIBUTING.md) for more details about how to contribute. We're looking forward to your contribution!
|
||||
|
||||
### Contributors
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
## [cli@1.34.8] - 2025-11-19
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- *(cli)* Update traefik (#3710)
|
||||
|
||||
## [cli@1.34.7] - 2025-11-13
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
@@ -250,7 +250,7 @@ func traefik(subdomain, projectName string, port uint, dotnhostfolder string) (*
|
||||
}
|
||||
|
||||
return &Service{
|
||||
Image: "traefik:v3.1",
|
||||
Image: "traefik:v3.6",
|
||||
DependsOn: nil,
|
||||
EntryPoint: nil,
|
||||
Command: []string{
|
||||
|
||||
@@ -4191,32 +4191,34 @@ type Plans struct {
|
||||
// An array relationship
|
||||
Organizations []*Organizations `json:"organizations"`
|
||||
Price int64 `json:"price"`
|
||||
SLALevel SLALevelEnum `json:"slaLevel"`
|
||||
Sort int64 `json:"sort"`
|
||||
UpatedAt time.Time `json:"upatedAt"`
|
||||
}
|
||||
|
||||
// Boolean expression to filter rows from the table "plans". All fields are combined with a logical 'AND'.
|
||||
type PlansBoolExp struct {
|
||||
And []*PlansBoolExp `json:"_and,omitempty"`
|
||||
Not *PlansBoolExp `json:"_not,omitempty"`
|
||||
Or []*PlansBoolExp `json:"_or,omitempty"`
|
||||
Apps *AppsBoolExp `json:"apps,omitempty"`
|
||||
CreatedAt *TimestamptzComparisonExp `json:"createdAt,omitempty"`
|
||||
Deprecated *BooleanComparisonExp `json:"deprecated,omitempty"`
|
||||
FeatureBackupEnabled *BooleanComparisonExp `json:"featureBackupEnabled,omitempty"`
|
||||
FeatureCustomDomainsEnabled *BooleanComparisonExp `json:"featureCustomDomainsEnabled,omitempty"`
|
||||
FeatureCustomEmailTemplatesEnabled *BooleanComparisonExp `json:"featureCustomEmailTemplatesEnabled,omitempty"`
|
||||
FeatureMaxDbSize *IntComparisonExp `json:"featureMaxDbSize,omitempty"`
|
||||
ID *UUIDComparisonExp `json:"id,omitempty"`
|
||||
Individual *BooleanComparisonExp `json:"individual,omitempty"`
|
||||
IsDefault *BooleanComparisonExp `json:"isDefault,omitempty"`
|
||||
IsFree *BooleanComparisonExp `json:"isFree,omitempty"`
|
||||
IsPublic *BooleanComparisonExp `json:"isPublic,omitempty"`
|
||||
Name *StringComparisonExp `json:"name,omitempty"`
|
||||
Organizations *OrganizationsBoolExp `json:"organizations,omitempty"`
|
||||
Price *IntComparisonExp `json:"price,omitempty"`
|
||||
Sort *IntComparisonExp `json:"sort,omitempty"`
|
||||
UpatedAt *TimestamptzComparisonExp `json:"upatedAt,omitempty"`
|
||||
And []*PlansBoolExp `json:"_and,omitempty"`
|
||||
Not *PlansBoolExp `json:"_not,omitempty"`
|
||||
Or []*PlansBoolExp `json:"_or,omitempty"`
|
||||
Apps *AppsBoolExp `json:"apps,omitempty"`
|
||||
CreatedAt *TimestamptzComparisonExp `json:"createdAt,omitempty"`
|
||||
Deprecated *BooleanComparisonExp `json:"deprecated,omitempty"`
|
||||
FeatureBackupEnabled *BooleanComparisonExp `json:"featureBackupEnabled,omitempty"`
|
||||
FeatureCustomDomainsEnabled *BooleanComparisonExp `json:"featureCustomDomainsEnabled,omitempty"`
|
||||
FeatureCustomEmailTemplatesEnabled *BooleanComparisonExp `json:"featureCustomEmailTemplatesEnabled,omitempty"`
|
||||
FeatureMaxDbSize *IntComparisonExp `json:"featureMaxDbSize,omitempty"`
|
||||
ID *UUIDComparisonExp `json:"id,omitempty"`
|
||||
Individual *BooleanComparisonExp `json:"individual,omitempty"`
|
||||
IsDefault *BooleanComparisonExp `json:"isDefault,omitempty"`
|
||||
IsFree *BooleanComparisonExp `json:"isFree,omitempty"`
|
||||
IsPublic *BooleanComparisonExp `json:"isPublic,omitempty"`
|
||||
Name *StringComparisonExp `json:"name,omitempty"`
|
||||
Organizations *OrganizationsBoolExp `json:"organizations,omitempty"`
|
||||
Price *IntComparisonExp `json:"price,omitempty"`
|
||||
SLALevel *SLALevelEnumComparisonExp `json:"slaLevel,omitempty"`
|
||||
Sort *IntComparisonExp `json:"sort,omitempty"`
|
||||
UpatedAt *TimestamptzComparisonExp `json:"upatedAt,omitempty"`
|
||||
}
|
||||
|
||||
// Ordering options when selecting data from "plans".
|
||||
@@ -4236,6 +4238,7 @@ type PlansOrderBy struct {
|
||||
Name *OrderBy `json:"name,omitempty"`
|
||||
OrganizationsAggregate *OrganizationsAggregateOrderBy `json:"organizations_aggregate,omitempty"`
|
||||
Price *OrderBy `json:"price,omitempty"`
|
||||
SLALevel *OrderBy `json:"slaLevel,omitempty"`
|
||||
Sort *OrderBy `json:"sort,omitempty"`
|
||||
UpatedAt *OrderBy `json:"upatedAt,omitempty"`
|
||||
}
|
||||
@@ -4250,21 +4253,22 @@ type PlansStreamCursorInput struct {
|
||||
|
||||
// Initial value of the column from where the streaming should start
|
||||
type PlansStreamCursorValueInput struct {
|
||||
CreatedAt *time.Time `json:"createdAt,omitempty"`
|
||||
Deprecated *bool `json:"deprecated,omitempty"`
|
||||
FeatureBackupEnabled *bool `json:"featureBackupEnabled,omitempty"`
|
||||
FeatureCustomDomainsEnabled *bool `json:"featureCustomDomainsEnabled,omitempty"`
|
||||
FeatureCustomEmailTemplatesEnabled *bool `json:"featureCustomEmailTemplatesEnabled,omitempty"`
|
||||
FeatureMaxDbSize *int64 `json:"featureMaxDbSize,omitempty"`
|
||||
ID *string `json:"id,omitempty"`
|
||||
Individual *bool `json:"individual,omitempty"`
|
||||
IsDefault *bool `json:"isDefault,omitempty"`
|
||||
IsFree *bool `json:"isFree,omitempty"`
|
||||
IsPublic *bool `json:"isPublic,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Price *int64 `json:"price,omitempty"`
|
||||
Sort *int64 `json:"sort,omitempty"`
|
||||
UpatedAt *time.Time `json:"upatedAt,omitempty"`
|
||||
CreatedAt *time.Time `json:"createdAt,omitempty"`
|
||||
Deprecated *bool `json:"deprecated,omitempty"`
|
||||
FeatureBackupEnabled *bool `json:"featureBackupEnabled,omitempty"`
|
||||
FeatureCustomDomainsEnabled *bool `json:"featureCustomDomainsEnabled,omitempty"`
|
||||
FeatureCustomEmailTemplatesEnabled *bool `json:"featureCustomEmailTemplatesEnabled,omitempty"`
|
||||
FeatureMaxDbSize *int64 `json:"featureMaxDbSize,omitempty"`
|
||||
ID *string `json:"id,omitempty"`
|
||||
Individual *bool `json:"individual,omitempty"`
|
||||
IsDefault *bool `json:"isDefault,omitempty"`
|
||||
IsFree *bool `json:"isFree,omitempty"`
|
||||
IsPublic *bool `json:"isPublic,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Price *int64 `json:"price,omitempty"`
|
||||
SLALevel *SLALevelEnum `json:"slaLevel,omitempty"`
|
||||
Sort *int64 `json:"sort,omitempty"`
|
||||
UpatedAt *time.Time `json:"upatedAt,omitempty"`
|
||||
}
|
||||
|
||||
type QueryRoot struct {
|
||||
@@ -4694,6 +4698,15 @@ type RunServiceStreamCursorValueInput struct {
|
||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
// Boolean expression to compare columns of type "sla_level_enum". All fields are combined with logical 'AND'.
|
||||
type SLALevelEnumComparisonExp struct {
|
||||
Eq *SLALevelEnum `json:"_eq,omitempty"`
|
||||
In []SLALevelEnum `json:"_in,omitempty"`
|
||||
IsNull *bool `json:"_is_null,omitempty"`
|
||||
Neq *SLALevelEnum `json:"_neq,omitempty"`
|
||||
Nin []SLALevelEnum `json:"_nin,omitempty"`
|
||||
}
|
||||
|
||||
// Boolean expression to compare columns of type "software_type_enum". All fields are combined with logical 'AND'.
|
||||
type SoftwareTypeEnumComparisonExp struct {
|
||||
Eq *SoftwareTypeEnum `json:"_eq,omitempty"`
|
||||
@@ -8542,6 +8555,8 @@ const (
|
||||
// column name
|
||||
PlansSelectColumnPrice PlansSelectColumn = "price"
|
||||
// column name
|
||||
PlansSelectColumnSLALevel PlansSelectColumn = "slaLevel"
|
||||
// column name
|
||||
PlansSelectColumnSort PlansSelectColumn = "sort"
|
||||
// column name
|
||||
PlansSelectColumnUpatedAt PlansSelectColumn = "upatedAt"
|
||||
@@ -8561,13 +8576,14 @@ var AllPlansSelectColumn = []PlansSelectColumn{
|
||||
PlansSelectColumnIsPublic,
|
||||
PlansSelectColumnName,
|
||||
PlansSelectColumnPrice,
|
||||
PlansSelectColumnSLALevel,
|
||||
PlansSelectColumnSort,
|
||||
PlansSelectColumnUpatedAt,
|
||||
}
|
||||
|
||||
func (e PlansSelectColumn) IsValid() bool {
|
||||
switch e {
|
||||
case PlansSelectColumnCreatedAt, PlansSelectColumnDeprecated, PlansSelectColumnFeatureBackupEnabled, PlansSelectColumnFeatureCustomDomainsEnabled, PlansSelectColumnFeatureCustomEmailTemplatesEnabled, PlansSelectColumnFeatureMaxDbSize, PlansSelectColumnID, PlansSelectColumnIndividual, PlansSelectColumnIsDefault, PlansSelectColumnIsFree, PlansSelectColumnIsPublic, PlansSelectColumnName, PlansSelectColumnPrice, PlansSelectColumnSort, PlansSelectColumnUpatedAt:
|
||||
case PlansSelectColumnCreatedAt, PlansSelectColumnDeprecated, PlansSelectColumnFeatureBackupEnabled, PlansSelectColumnFeatureCustomDomainsEnabled, PlansSelectColumnFeatureCustomEmailTemplatesEnabled, PlansSelectColumnFeatureMaxDbSize, PlansSelectColumnID, PlansSelectColumnIndividual, PlansSelectColumnIsDefault, PlansSelectColumnIsFree, PlansSelectColumnIsPublic, PlansSelectColumnName, PlansSelectColumnPrice, PlansSelectColumnSLALevel, PlansSelectColumnSort, PlansSelectColumnUpatedAt:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -8954,6 +8970,66 @@ func (e RunServiceSelectColumn) MarshalJSON() ([]byte, error) {
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type SLALevelEnum string
|
||||
|
||||
const (
|
||||
// No SLA
|
||||
SLALevelEnumNone SLALevelEnum = "none"
|
||||
// Premium SLA
|
||||
SLALevelEnumPremium SLALevelEnum = "premium"
|
||||
// Standard SLA
|
||||
SLALevelEnumStandard SLALevelEnum = "standard"
|
||||
)
|
||||
|
||||
var AllSLALevelEnum = []SLALevelEnum{
|
||||
SLALevelEnumNone,
|
||||
SLALevelEnumPremium,
|
||||
SLALevelEnumStandard,
|
||||
}
|
||||
|
||||
func (e SLALevelEnum) IsValid() bool {
|
||||
switch e {
|
||||
case SLALevelEnumNone, SLALevelEnumPremium, SLALevelEnumStandard:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e SLALevelEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *SLALevelEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
}
|
||||
|
||||
*e = SLALevelEnum(str)
|
||||
if !e.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid sla_level_enum", str)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e SLALevelEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *SLALevelEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e SLALevelEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type SoftwareTypeEnum string
|
||||
|
||||
const (
|
||||
|
||||
@@ -233,7 +233,8 @@
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild@<=0.24.2": ">=0.25.0",
|
||||
"js-yaml@<=4.1.0": ">=4.1.1"
|
||||
"js-yaml@<=4.1.0": ">=4.1.1",
|
||||
"glob@>=10.3.7 <=11.0.3": ">=11.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
99
dashboard/pnpm-lock.yaml
generated
99
dashboard/pnpm-lock.yaml
generated
@@ -7,6 +7,7 @@ settings:
|
||||
overrides:
|
||||
esbuild@<=0.24.2: '>=0.25.0'
|
||||
js-yaml@<=4.1.0: '>=4.1.1'
|
||||
glob@>=10.3.7 <=11.0.3: '>=11.1.0'
|
||||
|
||||
packageExtensionsChecksum: sha256-gRFeykwiwMfEE6etcYx6N48XwVeKzxbqNveL7KTQgSQ=
|
||||
|
||||
@@ -2246,6 +2247,14 @@ packages:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@isaacs/balanced-match@4.0.1':
|
||||
resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
'@isaacs/brace-expansion@5.0.0':
|
||||
resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
'@isaacs/cliui@8.0.2':
|
||||
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -2619,10 +2628,6 @@ packages:
|
||||
'@orval/zod@7.11.2':
|
||||
resolution: {integrity: sha512-4MzTg5Wms8/LlM3CbYu80dvCbP88bVlQjnYsBdFXuEv0K2GYkBCAhVOrmXCVrPXE89neV6ABkvWQeuKZQpkdxQ==}
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@playwright/test@1.54.1':
|
||||
resolution: {integrity: sha512-FS8hQ12acieG2dYSksmLOF7BNxnVf2afRJdCuM1eMSxj6QTSE6G4InGF7oApGgDb65MX7AwMVlIkpru0yZA4Xw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -5650,8 +5655,8 @@ packages:
|
||||
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
foreground-child@3.1.1:
|
||||
resolution: {integrity: sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==}
|
||||
foreground-child@3.3.1:
|
||||
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
form-data@4.0.4:
|
||||
@@ -5780,8 +5785,9 @@ packages:
|
||||
glob-to-regexp@0.4.1:
|
||||
resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==}
|
||||
|
||||
glob@10.4.5:
|
||||
resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==}
|
||||
glob@12.0.0:
|
||||
resolution: {integrity: sha512-5Qcll1z7IKgHr5g485ePDdHcNQY0k2dtv/bjYy0iuyGxQw2qSOiiXUXJ+AYQpg3HNoUMHqAruX478Jeev7UULw==}
|
||||
engines: {node: 20 || >=22}
|
||||
hasBin: true
|
||||
|
||||
glob@7.1.7:
|
||||
@@ -6289,9 +6295,9 @@ packages:
|
||||
iterator.prototype@1.1.2:
|
||||
resolution: {integrity: sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==}
|
||||
|
||||
jackspeak@3.2.3:
|
||||
resolution: {integrity: sha512-htOzIMPbpLid/Gq9/zaz9SfExABxqRe1sSCdxntlO/aMD6u0issZQiY25n2GKQUtJ02j7z5sfptlAOMpWWOmvw==}
|
||||
engines: {node: '>=14'}
|
||||
jackspeak@4.1.1:
|
||||
resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
jest-diff@29.7.0:
|
||||
resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==}
|
||||
@@ -6556,9 +6562,9 @@ packages:
|
||||
lowlight@3.1.0:
|
||||
resolution: {integrity: sha512-CEbNVoSikAxwDMDPjXlqlFYiZLkDJHwyGu/MfOsJnF3d7f3tds5J3z8s/l9TMXhzfsJCCJEAsD78842mwmg0PQ==}
|
||||
|
||||
lru-cache@10.2.2:
|
||||
resolution: {integrity: sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==}
|
||||
engines: {node: 14 || >=16.14}
|
||||
lru-cache@11.2.2:
|
||||
resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
lru-cache@5.1.1:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
@@ -6799,6 +6805,10 @@ packages:
|
||||
resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==}
|
||||
hasBin: true
|
||||
|
||||
minimatch@10.1.1:
|
||||
resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
minimatch@3.1.2:
|
||||
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
|
||||
|
||||
@@ -6810,10 +6820,6 @@ packages:
|
||||
resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
minimatch@9.0.4:
|
||||
resolution: {integrity: sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
minimatch@9.0.5:
|
||||
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
@@ -7182,9 +7188,9 @@ packages:
|
||||
resolution: {integrity: sha512-QLcPegTHF11axjfojBIoDygmS2E3Lf+8+jI6wOVmNVenrKSo3mFdSGiIgdSHenczw3wPtlVMQaFVwGmM7BJdtg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
path-scurry@1.11.1:
|
||||
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
|
||||
engines: {node: '>=16 || 14 >=14.18'}
|
||||
path-scurry@2.0.1:
|
||||
resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
path-to-regexp@6.3.0:
|
||||
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
|
||||
@@ -11153,11 +11159,17 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/node': 20.14.8
|
||||
|
||||
'@isaacs/balanced-match@4.0.1': {}
|
||||
|
||||
'@isaacs/brace-expansion@5.0.0':
|
||||
dependencies:
|
||||
'@isaacs/balanced-match': 4.0.1
|
||||
|
||||
'@isaacs/cliui@8.0.2':
|
||||
dependencies:
|
||||
string-width: 5.1.2
|
||||
string-width-cjs: string-width@4.2.3
|
||||
strip-ansi: 7.1.0
|
||||
strip-ansi: 7.1.2
|
||||
strip-ansi-cjs: strip-ansi@6.0.1
|
||||
wrap-ansi: 8.1.0
|
||||
wrap-ansi-cjs: wrap-ansi@7.0.0
|
||||
@@ -11605,9 +11617,6 @@ snapshots:
|
||||
- openapi-types
|
||||
- supports-color
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
optional: true
|
||||
|
||||
'@playwright/test@1.54.1':
|
||||
dependencies:
|
||||
playwright: 1.54.1
|
||||
@@ -15242,7 +15251,7 @@ snapshots:
|
||||
dependencies:
|
||||
is-callable: 1.2.7
|
||||
|
||||
foreground-child@3.1.1:
|
||||
foreground-child@3.3.1:
|
||||
dependencies:
|
||||
cross-spawn: 7.0.6
|
||||
signal-exit: 4.1.0
|
||||
@@ -15383,14 +15392,14 @@ snapshots:
|
||||
|
||||
glob-to-regexp@0.4.1: {}
|
||||
|
||||
glob@10.4.5:
|
||||
glob@12.0.0:
|
||||
dependencies:
|
||||
foreground-child: 3.1.1
|
||||
jackspeak: 3.2.3
|
||||
minimatch: 9.0.4
|
||||
foreground-child: 3.3.1
|
||||
jackspeak: 4.1.1
|
||||
minimatch: 10.1.1
|
||||
minipass: 7.1.2
|
||||
package-json-from-dist: 1.0.1
|
||||
path-scurry: 1.11.1
|
||||
path-scurry: 2.0.1
|
||||
|
||||
glob@7.1.7:
|
||||
dependencies:
|
||||
@@ -15913,11 +15922,9 @@ snapshots:
|
||||
reflect.getprototypeof: 1.0.8
|
||||
set-function-name: 2.0.2
|
||||
|
||||
jackspeak@3.2.3:
|
||||
jackspeak@4.1.1:
|
||||
dependencies:
|
||||
'@isaacs/cliui': 8.0.2
|
||||
optionalDependencies:
|
||||
'@pkgjs/parseargs': 0.11.0
|
||||
|
||||
jest-diff@29.7.0:
|
||||
dependencies:
|
||||
@@ -16211,7 +16218,7 @@ snapshots:
|
||||
devlop: 1.1.0
|
||||
highlight.js: 11.9.0
|
||||
|
||||
lru-cache@10.2.2: {}
|
||||
lru-cache@11.2.2: {}
|
||||
|
||||
lru-cache@5.1.1:
|
||||
dependencies:
|
||||
@@ -16641,6 +16648,10 @@ snapshots:
|
||||
|
||||
mini-svg-data-uri@1.4.4: {}
|
||||
|
||||
minimatch@10.1.1:
|
||||
dependencies:
|
||||
'@isaacs/brace-expansion': 5.0.0
|
||||
|
||||
minimatch@3.1.2:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.12
|
||||
@@ -16653,10 +16664,6 @@ snapshots:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.2
|
||||
|
||||
minimatch@9.0.4:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.2
|
||||
|
||||
minimatch@9.0.5:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.2
|
||||
@@ -17095,9 +17102,9 @@ snapshots:
|
||||
dependencies:
|
||||
path-root-regex: 0.1.2
|
||||
|
||||
path-scurry@1.11.1:
|
||||
path-scurry@2.0.1:
|
||||
dependencies:
|
||||
lru-cache: 10.2.2
|
||||
lru-cache: 11.2.2
|
||||
minipass: 7.1.2
|
||||
|
||||
path-to-regexp@6.3.0: {}
|
||||
@@ -17943,7 +17950,7 @@ snapshots:
|
||||
dependencies:
|
||||
eastasianwidth: 0.2.0
|
||||
emoji-regex: 9.2.2
|
||||
strip-ansi: 7.1.0
|
||||
strip-ansi: 7.1.2
|
||||
|
||||
string-width@7.2.0:
|
||||
dependencies:
|
||||
@@ -18062,7 +18069,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
commander: 4.1.1
|
||||
glob: 10.4.5
|
||||
glob: 12.0.0
|
||||
lines-and-columns: 1.2.4
|
||||
mz: 2.7.0
|
||||
pirates: 4.0.6
|
||||
@@ -18164,7 +18171,7 @@ snapshots:
|
||||
test-exclude@7.0.1:
|
||||
dependencies:
|
||||
'@istanbuljs/schema': 0.1.3
|
||||
glob: 10.4.5
|
||||
glob: 12.0.0
|
||||
minimatch: 9.0.5
|
||||
|
||||
text-table@0.2.0: {}
|
||||
@@ -18790,9 +18797,9 @@ snapshots:
|
||||
|
||||
wrap-ansi@8.1.0:
|
||||
dependencies:
|
||||
ansi-styles: 6.2.1
|
||||
ansi-styles: 6.2.3
|
||||
string-width: 5.1.2
|
||||
strip-ansi: 7.1.0
|
||||
strip-ansi: 7.1.2
|
||||
|
||||
wrap-ansi@9.0.0:
|
||||
dependencies:
|
||||
|
||||
@@ -21,23 +21,22 @@ import { ReplicasFormSection } from '@/features/orgs/projects/services/component
|
||||
import { StorageFormSection } from '@/features/orgs/projects/services/components/ServiceForm/components/StorageFormSection';
|
||||
|
||||
import {
|
||||
defaultServiceFormValues,
|
||||
validationSchema,
|
||||
type Port,
|
||||
type ServiceFormProps,
|
||||
type ServiceFormValues,
|
||||
} from '@/features/orgs/projects/services/components/ServiceForm/ServiceFormTypes';
|
||||
|
||||
import { useLocalMimirClient } from '@/features/orgs/projects/hooks/useLocalMimirClient';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { getFormattedServiceConfig } from '@/features/orgs/projects/services/utils/getFormattedServiceConfig';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import {
|
||||
useInsertRunServiceConfigMutation,
|
||||
useReplaceRunServiceConfigMutation,
|
||||
type ConfigRunServiceConfigInsertInput,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { RESOURCE_VCPU_MULTIPLIER } from '@/utils/constants/common';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { removeTypename } from '@/utils/helpers';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
@@ -69,14 +68,7 @@ export default function ServiceForm({
|
||||
useState<Error | null>(null);
|
||||
|
||||
const form = useForm<ServiceFormValues>({
|
||||
defaultValues: initialData ?? {
|
||||
compute: {
|
||||
cpu: 62,
|
||||
memory: 128,
|
||||
},
|
||||
replicas: 1,
|
||||
autoscaler: null,
|
||||
},
|
||||
defaultValues: initialData ?? defaultServiceFormValues,
|
||||
reValidateMode: 'onSubmit',
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
@@ -142,66 +134,8 @@ export default function ServiceForm({
|
||||
onDirtyStateChange(isDirty, location);
|
||||
}, [isDirty, location, onDirtyStateChange]);
|
||||
|
||||
const getFormattedConfig = (values: ServiceFormValues) => {
|
||||
// Remove any __typename property from the values
|
||||
const sanitizedValues = removeTypename(values) as ServiceFormValues;
|
||||
const sanitizedInitialDataPorts: Port[] = initialData?.ports
|
||||
? removeTypename(initialData.ports)
|
||||
: [];
|
||||
|
||||
const config: ConfigRunServiceConfigInsertInput = {
|
||||
name: sanitizedValues.name,
|
||||
image: {
|
||||
image: sanitizedValues.image,
|
||||
pullCredentials: sanitizedValues.pullCredentials,
|
||||
},
|
||||
command: sanitizedValues.command?.map((arg) => arg.argument),
|
||||
resources: {
|
||||
compute: {
|
||||
cpu: sanitizedValues.compute?.cpu,
|
||||
memory: sanitizedValues.compute?.memory,
|
||||
},
|
||||
storage: sanitizedValues.storage?.map((item) => ({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
capacity: item.capacity,
|
||||
})),
|
||||
replicas: sanitizedValues.replicas,
|
||||
autoscaler: sanitizedValues.autoscaler
|
||||
? {
|
||||
maxReplicas: sanitizedValues.autoscaler?.maxReplicas,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
environment: sanitizedValues.environment?.map((item) => ({
|
||||
name: item.name,
|
||||
value: item.value,
|
||||
})),
|
||||
ports: sanitizedValues.ports?.map((item) => ({
|
||||
port: item.port,
|
||||
type: item.type,
|
||||
publish: item.publish,
|
||||
ingresses: item.ingresses as any, // cannot be changed on the UI always null type checking can be skipped.
|
||||
rateLimit:
|
||||
sanitizedInitialDataPorts.find(
|
||||
(port) => port.port === item.port && port.type === item.type,
|
||||
)?.rateLimit ?? (null as any), // cannot be changed on the UI always null type checking can be skipped.
|
||||
})),
|
||||
healthCheck: sanitizedValues.healthCheck
|
||||
? {
|
||||
port: sanitizedValues.healthCheck?.port,
|
||||
initialDelaySeconds:
|
||||
sanitizedValues.healthCheck?.initialDelaySeconds,
|
||||
probePeriodSeconds: sanitizedValues.healthCheck?.probePeriodSeconds,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
const createOrUpdateService = async (values: ServiceFormValues) => {
|
||||
const config = getFormattedConfig(values);
|
||||
const config = getFormattedServiceConfig({ values, initialData });
|
||||
|
||||
if (serviceID) {
|
||||
// Update service config
|
||||
@@ -292,7 +226,10 @@ export default function ServiceForm({
|
||||
};
|
||||
|
||||
const copyConfig = () => {
|
||||
const config = getFormattedConfig(formValues);
|
||||
const config = getFormattedServiceConfig({
|
||||
values: formValues,
|
||||
initialData,
|
||||
});
|
||||
|
||||
const base64Config = btoa(JSON.stringify(config));
|
||||
|
||||
|
||||
@@ -87,6 +87,15 @@ export type ServiceFormInitialData = Omit<ServiceFormValues, 'ports'> & {
|
||||
}[];
|
||||
};
|
||||
|
||||
export const defaultServiceFormValues = {
|
||||
compute: {
|
||||
cpu: 62,
|
||||
memory: 128,
|
||||
},
|
||||
replicas: 1,
|
||||
autoscaler: null,
|
||||
};
|
||||
|
||||
export interface ServiceFormProps extends DialogFormProps {
|
||||
/**
|
||||
* To use in conjunction with initialData to allow for updating the service
|
||||
|
||||
@@ -15,7 +15,10 @@ import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatfo
|
||||
import { type RunService } from '@/features/orgs/projects/common/hooks/useRunServices';
|
||||
import { ServiceForm } from '@/features/orgs/projects/services/components/ServiceForm';
|
||||
import { type PortTypes } from '@/features/orgs/projects/services/components/ServiceForm/components/PortsFormSection/PortsFormSectionTypes';
|
||||
import type { ServiceFormInitialData } from '@/features/orgs/projects/services/components/ServiceForm/ServiceFormTypes';
|
||||
import {
|
||||
defaultServiceFormValues,
|
||||
type ServiceFormInitialData,
|
||||
} from '@/features/orgs/projects/services/components/ServiceForm/ServiceFormTypes';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
@@ -74,12 +77,15 @@ export default function ServicesList({
|
||||
ingresses: item.ingresses,
|
||||
rateLimit: item.rateLimit,
|
||||
})),
|
||||
compute: service.config?.resources?.compute ?? {
|
||||
cpu: 62,
|
||||
memory: 128,
|
||||
},
|
||||
replicas: service.config?.resources?.replicas,
|
||||
autoscaler: service?.config?.resources?.autoscaler,
|
||||
compute:
|
||||
service.config?.resources?.compute ??
|
||||
defaultServiceFormValues.compute,
|
||||
replicas:
|
||||
service.config?.resources?.replicas ??
|
||||
defaultServiceFormValues.replicas,
|
||||
autoscaler:
|
||||
service?.config?.resources?.autoscaler ??
|
||||
defaultServiceFormValues.autoscaler,
|
||||
storage: service.config?.resources?.storage,
|
||||
} as ServiceFormInitialData
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { PortTypes } from '@/features/orgs/projects/services/components/ServiceForm/components/PortsFormSection/PortsFormSectionTypes';
|
||||
import getFormattedServiceConfig from './getFormattedServiceConfig';
|
||||
|
||||
describe('getFormattedServiceConfig', () => {
|
||||
it('pghero config should be formatted correctly', () => {
|
||||
const pgheroFormValues = {
|
||||
name: 'pghero',
|
||||
image: 'docker.io/ankane/pghero:latest',
|
||||
command: [],
|
||||
resources: {
|
||||
compute: {
|
||||
cpu: 125,
|
||||
memory: 256,
|
||||
},
|
||||
storage: [],
|
||||
replicas: 1,
|
||||
},
|
||||
environment: [
|
||||
{
|
||||
name: 'DATABASE_URL',
|
||||
value:
|
||||
'postgres://postgres:[PASSWORD]@postgres-service:5432/[SUBDOMAIN]?sslmode=disable',
|
||||
},
|
||||
{
|
||||
name: 'PGHERO_USERNAME',
|
||||
value: '[USER]',
|
||||
},
|
||||
{
|
||||
name: 'PGHERO_PASSWORD',
|
||||
value: '[PASSWORD]',
|
||||
},
|
||||
],
|
||||
ports: [
|
||||
{
|
||||
port: 8080,
|
||||
type: PortTypes.HTTP,
|
||||
publish: true,
|
||||
},
|
||||
],
|
||||
autoscaler: null,
|
||||
compute: {
|
||||
cpu: 125,
|
||||
memory: 256,
|
||||
},
|
||||
replicas: 1,
|
||||
storage: [],
|
||||
};
|
||||
|
||||
const formattedConfig = getFormattedServiceConfig({
|
||||
values: pgheroFormValues,
|
||||
});
|
||||
|
||||
const expected = {
|
||||
name: 'pghero',
|
||||
image: {
|
||||
image: 'docker.io/ankane/pghero:latest',
|
||||
},
|
||||
command: [],
|
||||
resources: {
|
||||
compute: {
|
||||
cpu: 125,
|
||||
memory: 256,
|
||||
},
|
||||
storage: [],
|
||||
replicas: 1,
|
||||
autoscaler: null,
|
||||
},
|
||||
environment: [
|
||||
{
|
||||
name: 'DATABASE_URL',
|
||||
value:
|
||||
'postgres://postgres:[PASSWORD]@postgres-service:5432/[SUBDOMAIN]?sslmode=disable',
|
||||
},
|
||||
{
|
||||
name: 'PGHERO_USERNAME',
|
||||
value: '[USER]',
|
||||
},
|
||||
{
|
||||
name: 'PGHERO_PASSWORD',
|
||||
value: '[PASSWORD]',
|
||||
},
|
||||
],
|
||||
ports: [
|
||||
{
|
||||
port: 8080,
|
||||
type: 'http',
|
||||
publish: true,
|
||||
rateLimit: null,
|
||||
},
|
||||
],
|
||||
healthCheck: null,
|
||||
};
|
||||
|
||||
expect(formattedConfig).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import type {
|
||||
Port,
|
||||
ServiceFormInitialData,
|
||||
ServiceFormValues,
|
||||
} from '@/features/orgs/projects/services/components/ServiceForm/ServiceFormTypes';
|
||||
import type { ConfigRunServiceConfigInsertInput } from '@/utils/__generated__/graphql';
|
||||
import { removeTypename } from '@/utils/helpers';
|
||||
|
||||
export interface GetFormattedServiceConfigProps {
|
||||
values: ServiceFormValues;
|
||||
initialData?: ServiceFormInitialData;
|
||||
}
|
||||
|
||||
export default function getFormattedServiceConfig({
|
||||
values,
|
||||
initialData,
|
||||
}: GetFormattedServiceConfigProps) {
|
||||
// Remove any __typename property from the values
|
||||
const sanitizedValues = removeTypename(values) as ServiceFormValues;
|
||||
const sanitizedInitialDataPorts: Port[] = initialData?.ports
|
||||
? removeTypename(initialData.ports)
|
||||
: [];
|
||||
|
||||
const config: ConfigRunServiceConfigInsertInput = {
|
||||
name: sanitizedValues.name,
|
||||
image: {
|
||||
image: sanitizedValues.image,
|
||||
pullCredentials: sanitizedValues.pullCredentials,
|
||||
},
|
||||
command: sanitizedValues.command?.map((arg) => arg.argument),
|
||||
resources: {
|
||||
compute: {
|
||||
cpu: sanitizedValues.compute?.cpu,
|
||||
memory: sanitizedValues.compute?.memory,
|
||||
},
|
||||
storage: sanitizedValues.storage?.map((item) => ({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
capacity: item.capacity,
|
||||
})),
|
||||
replicas: sanitizedValues.replicas,
|
||||
autoscaler: sanitizedValues.autoscaler
|
||||
? {
|
||||
maxReplicas: sanitizedValues.autoscaler?.maxReplicas,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
environment: sanitizedValues.environment?.map((item) => ({
|
||||
name: item.name,
|
||||
value: item.value,
|
||||
})),
|
||||
ports: sanitizedValues.ports?.map((item) => ({
|
||||
port: item.port,
|
||||
type: item.type,
|
||||
publish: item.publish,
|
||||
ingresses: item.ingresses as any, // cannot be changed on the UI always null type checking can be skipped.
|
||||
rateLimit:
|
||||
sanitizedInitialDataPorts.find(
|
||||
(port) => port.port === item.port && port.type === item.type,
|
||||
)?.rateLimit ?? (null as any), // cannot be changed on the UI always null type checking can be skipped.
|
||||
})),
|
||||
healthCheck: sanitizedValues.healthCheck
|
||||
? {
|
||||
port: sanitizedValues.healthCheck?.port,
|
||||
initialDelaySeconds: sanitizedValues.healthCheck?.initialDelaySeconds,
|
||||
probePeriodSeconds: sanitizedValues.healthCheck?.probePeriodSeconds,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
return config;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as getFormattedServiceConfig } from './getFormattedServiceConfig';
|
||||
@@ -0,0 +1 @@
|
||||
export { default as parseConfigFromInstallLink } from './parseConfigFromInstallLink';
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import parseConfigFromInstallLink from './parseConfigFromInstallLink';
|
||||
|
||||
describe('parseConfigFromInstallLink', () => {
|
||||
it('pghero config without autoscaler should be formatted correctly', () => {
|
||||
const pgheroBase64Config =
|
||||
'eyJuYW1lIjoicGdoZXJvIiwiaW1hZ2UiOnsiaW1hZ2UiOiJkb2NrZXIuaW8vYW5rYW5lL3BnaGVybzpsYXRlc3QifSwiY29tbWFuZCI6W10sInJlc291cmNlcyI6eyJjb21wdXRlIjp7ImNwdSI6MTI1LCJtZW1vcnkiOjI1Nn0sInN0b3JhZ2UiOltdLCJyZXBsaWNhcyI6MX0sImVudmlyb25tZW50IjpbeyJuYW1lIjoiREFUQUJBU0VfVVJMIiwidmFsdWUiOiJwb3N0Z3JlczovL3Bvc3RncmVzOltQQVNTV09SRF1AcG9zdGdyZXMtc2VydmljZTo1NDMyL1tTVUJET01BSU5dP3NzbG1vZGU9ZGlzYWJsZSJ9LHsibmFtZSI6IlBHSEVST19VU0VSTkFNRSIsInZhbHVlIjoiW1VTRVJdIn0seyJuYW1lIjoiUEdIRVJPX1BBU1NXT1JEIiwidmFsdWUiOiJbUEFTU1dPUkRdIn1dLCJwb3J0cyI6W3sicG9ydCI6ODA4MCwidHlwZSI6Imh0dHAiLCJwdWJsaXNoIjp0cnVlfV19';
|
||||
|
||||
const config = parseConfigFromInstallLink(pgheroBase64Config);
|
||||
|
||||
const expected = {
|
||||
name: 'pghero',
|
||||
image: 'docker.io/ankane/pghero:latest',
|
||||
command: [],
|
||||
resources: {
|
||||
compute: {
|
||||
cpu: 125,
|
||||
memory: 256,
|
||||
},
|
||||
storage: [],
|
||||
replicas: 1,
|
||||
},
|
||||
environment: [
|
||||
{
|
||||
name: 'DATABASE_URL',
|
||||
value:
|
||||
'postgres://postgres:[PASSWORD]@postgres-service:5432/[SUBDOMAIN]?sslmode=disable',
|
||||
},
|
||||
{
|
||||
name: 'PGHERO_USERNAME',
|
||||
value: '[USER]',
|
||||
},
|
||||
{
|
||||
name: 'PGHERO_PASSWORD',
|
||||
value: '[PASSWORD]',
|
||||
},
|
||||
],
|
||||
ports: [
|
||||
{
|
||||
port: 8080,
|
||||
type: 'http',
|
||||
publish: true,
|
||||
},
|
||||
],
|
||||
autoscaler: null,
|
||||
compute: {
|
||||
cpu: 125,
|
||||
memory: 256,
|
||||
},
|
||||
replicas: 1,
|
||||
storage: [],
|
||||
};
|
||||
|
||||
expect(config).toEqual(expected);
|
||||
});
|
||||
|
||||
it('antivirus config without autoscaler should be formatted correctly', () => {
|
||||
const antivirusBase64Config =
|
||||
'eyJuYW1lIjoiY2xhbWF2IiwiaW1hZ2UiOnsiaW1hZ2UiOiJkb2NrZXIuaW8vbmhvc3QvY2xhbWF2OjAuMS4xIn0sImNvbW1hbmQiOltdLCJyZXNvdXJjZXMiOnsiY29tcHV0ZSI6eyJjcHUiOjEwMDAsIm1lbW9yeSI6MjA0OH0sInN0b3JhZ2UiOltdLCJyZXBsaWNhcyI6MX0sImVudmlyb25tZW50IjpbXSwicG9ydHMiOlt7InBvcnQiOiIzMzEwIiwidHlwZSI6InRjcCIsInB1Ymxpc2giOmZhbHNlfV19';
|
||||
|
||||
const config = parseConfigFromInstallLink(antivirusBase64Config);
|
||||
|
||||
const expected = {
|
||||
name: 'clamav',
|
||||
image: 'docker.io/nhost/clamav:0.1.1',
|
||||
command: [],
|
||||
resources: {
|
||||
compute: {
|
||||
cpu: 1000,
|
||||
memory: 2048,
|
||||
},
|
||||
storage: [],
|
||||
replicas: 1,
|
||||
},
|
||||
environment: [],
|
||||
ports: [
|
||||
{
|
||||
port: '3310',
|
||||
type: 'tcp',
|
||||
publish: false,
|
||||
},
|
||||
],
|
||||
autoscaler: null,
|
||||
compute: {
|
||||
cpu: 1000,
|
||||
memory: 2048,
|
||||
},
|
||||
replicas: 1,
|
||||
storage: [],
|
||||
};
|
||||
expect(config).toEqual(expected);
|
||||
});
|
||||
|
||||
it('invalid config should throw an error', () => {
|
||||
const invalidBase64Config = 'invalid';
|
||||
|
||||
expect(() => parseConfigFromInstallLink(invalidBase64Config)).toThrow();
|
||||
});
|
||||
|
||||
it('pghero config with autoscaler should be formatted correctly', () => {
|
||||
const pgheroWithAutoscalerBase64 =
|
||||
'eyJuYW1lIjoicGdoZXJvIiwiaW1hZ2UiOnsiaW1hZ2UiOiJkb2NrZXIuaW8vYW5rYW5lL3BnaGVybzpsYXRlc3QifSwiY29tbWFuZCI6W10sInJlc291cmNlcyI6eyJjb21wdXRlIjp7ImNwdSI6MTI1LCJtZW1vcnkiOjI1Nn0sInN0b3JhZ2UiOltdLCJyZXBsaWNhcyI6MSwiYXV0b3NjYWxlciI6eyJtYXhSZXBsaWNhcyI6MTF9fSwiZW52aXJvbm1lbnQiOlt7Im5hbWUiOiJEQVRBQkFTRV9VUkwiLCJ2YWx1ZSI6InBvc3RncmVzOi8vcG9zdGdyZXM6W1BBU1NXT1JEXUBwb3N0Z3Jlcy1zZXJ2aWNlOjU0MzIvW1NVQkRPTUFJTl0/c3NsbW9kZT1kaXNhYmxlIn0seyJuYW1lIjoiUEdIRVJPX1VTRVJOQU1FIiwidmFsdWUiOiJbVVNFUl0ifSx7Im5hbWUiOiJQR0hFUk9fUEFTU1dPUkQiLCJ2YWx1ZSI6IltQQVNTV09SRF0ifV0sInBvcnRzIjpbeyJwb3J0Ijo4MDgwLCJ0eXBlIjoiaHR0cCIsInB1Ymxpc2giOnRydWUsInJhdGVMaW1pdCI6bnVsbH1dLCJoZWFsdGhDaGVjayI6bnVsbH0=';
|
||||
|
||||
const config = parseConfigFromInstallLink(pgheroWithAutoscalerBase64);
|
||||
|
||||
const expected = {
|
||||
name: 'pghero',
|
||||
image: 'docker.io/ankane/pghero:latest',
|
||||
command: [],
|
||||
resources: {
|
||||
compute: {
|
||||
cpu: 125,
|
||||
memory: 256,
|
||||
},
|
||||
storage: [],
|
||||
replicas: 1,
|
||||
autoscaler: {
|
||||
maxReplicas: 11,
|
||||
},
|
||||
},
|
||||
environment: [
|
||||
{
|
||||
name: 'DATABASE_URL',
|
||||
value:
|
||||
'postgres://postgres:[PASSWORD]@postgres-service:5432/[SUBDOMAIN]?sslmode=disable',
|
||||
},
|
||||
{
|
||||
name: 'PGHERO_USERNAME',
|
||||
value: '[USER]',
|
||||
},
|
||||
{
|
||||
name: 'PGHERO_PASSWORD',
|
||||
value: '[PASSWORD]',
|
||||
},
|
||||
],
|
||||
ports: [
|
||||
{
|
||||
port: 8080,
|
||||
type: 'http',
|
||||
publish: true,
|
||||
},
|
||||
],
|
||||
autoscaler: {
|
||||
maxReplicas: 11,
|
||||
},
|
||||
compute: {
|
||||
cpu: 125,
|
||||
memory: 256,
|
||||
},
|
||||
replicas: 1,
|
||||
storage: [],
|
||||
};
|
||||
|
||||
expect(config).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { type RunServiceConfig } from '@/features/orgs/projects/common/hooks/useRunServices';
|
||||
import { type PortTypes } from '@/features/orgs/projects/services/components/ServiceForm/components/PortsFormSection/PortsFormSectionTypes';
|
||||
import {
|
||||
defaultServiceFormValues,
|
||||
type ServiceFormInitialData,
|
||||
} from '@/features/orgs/projects/services/components/ServiceForm/ServiceFormTypes';
|
||||
|
||||
export default function parseConfigFromInstallLink(
|
||||
base64Config: string,
|
||||
): ServiceFormInitialData {
|
||||
const decodedConfig = atob(base64Config);
|
||||
const parsedConfig: RunServiceConfig = JSON.parse(decodedConfig);
|
||||
const initialData = {
|
||||
...parsedConfig,
|
||||
autoscaler:
|
||||
parsedConfig?.resources?.autoscaler ??
|
||||
defaultServiceFormValues.autoscaler,
|
||||
compute:
|
||||
parsedConfig?.resources?.compute ?? defaultServiceFormValues.compute,
|
||||
image: parsedConfig?.image?.image,
|
||||
command: parsedConfig?.command?.map((arg) => ({
|
||||
argument: arg,
|
||||
})),
|
||||
environment:
|
||||
parsedConfig?.environment?.map((env) => ({
|
||||
name: env.name,
|
||||
value: env.value,
|
||||
})) ?? undefined,
|
||||
healthCheck: parsedConfig?.healthCheck
|
||||
? {
|
||||
port: parsedConfig.healthCheck.port ?? 3000,
|
||||
initialDelaySeconds:
|
||||
parsedConfig.healthCheck.initialDelaySeconds ?? 30,
|
||||
probePeriodSeconds: parsedConfig.healthCheck.probePeriodSeconds ?? 60,
|
||||
}
|
||||
: undefined,
|
||||
ports:
|
||||
parsedConfig?.ports?.map((item) => ({
|
||||
port: item.port ?? 3000,
|
||||
type: item.type as PortTypes,
|
||||
publish: Boolean(item.publish),
|
||||
})) ?? [],
|
||||
replicas: parsedConfig?.resources?.replicas,
|
||||
storage: parsedConfig?.resources?.storage ?? undefined,
|
||||
};
|
||||
|
||||
return initialData;
|
||||
}
|
||||
@@ -11,16 +11,12 @@ import { ServicesIcon } from '@/components/ui/v2/icons/ServicesIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { OrgLayout } from '@/features/orgs/layout/OrgLayout';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import {
|
||||
useRunServices,
|
||||
type RunServiceConfig,
|
||||
} from '@/features/orgs/projects/common/hooks/useRunServices';
|
||||
import { useRunServices } from '@/features/orgs/projects/common/hooks/useRunServices';
|
||||
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { ServiceForm } from '@/features/orgs/projects/services/components/ServiceForm';
|
||||
import { type PortTypes } from '@/features/orgs/projects/services/components/ServiceForm/components/PortsFormSection/PortsFormSectionTypes';
|
||||
import type { ServiceFormInitialData } from '@/features/orgs/projects/services/components/ServiceForm/ServiceFormTypes';
|
||||
import { ServicesList } from '@/features/orgs/projects/services/components/ServicesList';
|
||||
import { parseConfigFromInstallLink } from '@/features/orgs/projects/services/utils/parseConfigFromInstallLink';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useCallback, useEffect, type ReactElement } from 'react';
|
||||
|
||||
@@ -48,29 +44,7 @@ export default function RunPage() {
|
||||
(base64Config: string) => {
|
||||
if (router.query?.config) {
|
||||
try {
|
||||
const decodedConfig = atob(base64Config);
|
||||
const parsedConfig: RunServiceConfig = JSON.parse(decodedConfig);
|
||||
const initialData = {
|
||||
...parsedConfig,
|
||||
autoscaler: parsedConfig?.resources?.autoscaler ?? {
|
||||
maxReplicas: 0,
|
||||
},
|
||||
compute: parsedConfig?.resources?.compute ?? {
|
||||
cpu: 62,
|
||||
memory: 128,
|
||||
},
|
||||
image: parsedConfig?.image?.image,
|
||||
command: parsedConfig?.command?.map((arg) => ({
|
||||
argument: arg,
|
||||
})),
|
||||
ports: parsedConfig?.ports?.map((item) => ({
|
||||
port: item.port,
|
||||
type: item.type as PortTypes,
|
||||
publish: item.publish,
|
||||
})),
|
||||
replicas: parsedConfig?.resources?.replicas,
|
||||
storage: parsedConfig?.resources?.storage,
|
||||
} as ServiceFormInitialData;
|
||||
const initialData = parseConfigFromInstallLink(base64Config);
|
||||
|
||||
openDrawer({
|
||||
title: (
|
||||
|
||||
17
deploy/demos/Makefile
Normal file
17
deploy/demos/Makefile
Normal file
@@ -0,0 +1,17 @@
|
||||
ROOT_DIR?=$(abspath ../..)
|
||||
include $(ROOT_DIR)/build/makefiles/general.makefile
|
||||
|
||||
|
||||
.PHONY: _dev-env-up
|
||||
_dev-env-up:
|
||||
@echo "Nothing to do"
|
||||
|
||||
|
||||
.PHONY: _dev-env-down
|
||||
_dev-env-down:
|
||||
@echo "Nothing to do"
|
||||
|
||||
|
||||
.PHONY: _dev-env-build
|
||||
_dev-env-build:
|
||||
@echo "Nothing to do"
|
||||
39
deploy/demos/ReactNativeDemo/.gitignore
vendored
Normal file
39
deploy/demos/ReactNativeDemo/.gitignore
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
app-example
|
||||
95
deploy/demos/ReactNativeDemo/APPLE_SIGN_IN_SETUP.md
Normal file
95
deploy/demos/ReactNativeDemo/APPLE_SIGN_IN_SETUP.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Setting Up Apple Sign In for Nhost Authentication
|
||||
|
||||
This guide will walk you through the steps to configure Apple Sign In for your React Native application with Nhost authentication.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- An Apple Developer account
|
||||
- Xcode 11 or later
|
||||
- Access to the Apple Developer portal
|
||||
|
||||
## 1. Configure Your App in the Apple Developer Portal
|
||||
|
||||
1. Log in to the [Apple Developer portal](https://developer.apple.com/)
|
||||
2. Go to "Certificates, Identifiers & Profiles"
|
||||
3. Select "Identifiers" and create a new App ID if you haven't already
|
||||
4. Enable "Sign In with Apple" capability for your App ID
|
||||
5. Save your changes
|
||||
|
||||
## 2. Create a Service ID for Sign In with Apple
|
||||
|
||||
1. In the Apple Developer portal, go to "Certificates, Identifiers & Profiles"
|
||||
2. Select "Identifiers" and click the "+" button to add a new identifier
|
||||
3. Choose "Services IDs" and click "Continue"
|
||||
4. Enter a description and identifier (e.g., "com.nhost.reactnativewebdemo.service")
|
||||
5. Check "Sign In with Apple" and click "Configure"
|
||||
6. Add your domain to the "Domains and Subdomains" field
|
||||
7. Add your return URL in the "Return URLs" field. This should match your Nhost redirect URL
|
||||
8. Save and register the service ID
|
||||
|
||||
## 3. Configure Nhost for Apple Sign In
|
||||
|
||||
1. In your Nhost dashboard, go to Authentication > Providers > Apple
|
||||
2. Enable the provider
|
||||
3. Enter the following details:
|
||||
- Team ID: Found in your Apple Developer account
|
||||
- Service ID: The identifier you created in step 2
|
||||
- Key ID: Create a new key with "Sign In with Apple" enabled in the Apple Developer portal
|
||||
- Private Key: The downloaded key file content
|
||||
|
||||
## 4. Configure Your Expo/React Native App
|
||||
|
||||
1. Make sure the `expo-apple-authentication` package is installed
|
||||
|
||||
```
|
||||
npx expo install expo-apple-authentication
|
||||
```
|
||||
|
||||
2. Ensure your app.json has the proper configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"expo": {
|
||||
"ios": {
|
||||
"bundleIdentifier": "com.nhost.reactnativewebdemo",
|
||||
"infoPlist": {
|
||||
"NSFaceIDUsageDescription": "This app uses Face ID for signing in"
|
||||
}
|
||||
},
|
||||
"plugins": ["expo-apple-authentication"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. If you're using EAS Build, make sure you've configured your Apple Developer Team ID:
|
||||
```
|
||||
eas credentials
|
||||
```
|
||||
|
||||
## 5. Testing Apple Sign In
|
||||
|
||||
When you build your app for iOS:
|
||||
|
||||
1. Use a real device or simulator running iOS 13 or later
|
||||
2. Make sure you're signed into an Apple ID on the device
|
||||
3. Use the Apple Sign In button and authenticate
|
||||
4. The app will receive an ID token that is sent to Nhost
|
||||
5. Nhost will verify the token and create or authenticate the user
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Invalid Client ID**: Ensure your Service ID is properly configured in the Apple Developer portal
|
||||
- **Authentication Failed**: Check that your Nhost Apple provider configuration is correct
|
||||
- **App Build Issues**: Ensure the `expo-apple-authentication` package is properly installed and your app.json is configured correctly
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Never store Apple private keys in your front-end code
|
||||
- The authentication process should always validate tokens on the server side (which Nhost handles)
|
||||
- Keep your Apple Developer account secure
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Apple Sign In Documentation](https://developer.apple.com/sign-in-with-apple/)
|
||||
- [Nhost Authentication Documentation](https://docs.nhost.io/authentication)
|
||||
- [Expo Apple Authentication Documentation](https://docs.expo.dev/versions/latest/sdk/apple-authentication/)
|
||||
169
deploy/demos/ReactNativeDemo/README.md
Normal file
169
deploy/demos/ReactNativeDemo/README.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# Nhost SDK Demo - React Native
|
||||
|
||||
This is a comprehensive React Native demo showcasing the Nhost SDK integration with Expo. The application demonstrates various authentication methods, user management, file operations, and GraphQL interactions in a modern React Native environment.
|
||||
|
||||
## Features
|
||||
|
||||
- **Email/Password Authentication** - Traditional sign-up and sign-in with email
|
||||
- **Multi-Factor Authentication (MFA)** - TOTP-based 2FA security
|
||||
- **Magic Link Authentication** - Passwordless authentication via email
|
||||
- **Social Authentication** - GitHub OAuth integration
|
||||
- **Native Authentication** - Apple Sign-In for iOS devices
|
||||
- **User Profile Management** - Display and manage user information
|
||||
- **Protected Routes** - Route-based authentication guards
|
||||
- **Session Persistence** - Reliable session storage with AsyncStorage
|
||||
- **File Operations** - Upload and download functionality
|
||||
- **GraphQL Operations** - Database queries and mutations
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Install dependencies**
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
2. **Configure Nhost**
|
||||
|
||||
Update `app.json` with your Nhost configuration:
|
||||
|
||||
```json
|
||||
"extra": {
|
||||
"NHOST_SUBDOMAIN": "your-subdomain",
|
||||
"NHOST_REGION": "your-region"
|
||||
}
|
||||
```
|
||||
|
||||
For local development with Nhost CLI:
|
||||
|
||||
```json
|
||||
"extra": {
|
||||
"NHOST_SUBDOMAIN": "192-168-1-103",
|
||||
"NHOST_REGION": "local"
|
||||
}
|
||||
```
|
||||
|
||||
_(Replace with your actual local IP address using hyphens instead of dots)_
|
||||
|
||||
3. **Start the development server**
|
||||
|
||||
```bash
|
||||
pnpm start
|
||||
```
|
||||
|
||||
4. **Open the app**
|
||||
- Scan QR code with Expo Go
|
||||
- Press `i` for iOS Simulator
|
||||
- Press `a` for Android Emulator
|
||||
|
||||
## Project Structure
|
||||
|
||||
The project uses [Expo Router](https://docs.expo.dev/router/introduction/) for file-based navigation:
|
||||
|
||||
```
|
||||
ReactNativeWebDemo/
|
||||
├── app/
|
||||
│ ├── _layout.tsx # Root layout with AuthProvider
|
||||
│ ├── index.tsx # Home/landing screen
|
||||
│ ├── signin.tsx # Authentication hub with tabs
|
||||
│ ├── signup.tsx # User registration
|
||||
│ ├── profile.tsx # Protected user profile
|
||||
│ ├── upload.tsx # File upload demo
|
||||
│ ├── verify.tsx # Magic link/social auth verification
|
||||
│ │
|
||||
│ ├── components/
|
||||
│ │ ├── ProtectedScreen.tsx # Route protection wrapper
|
||||
│ │ ├── MagicLinkForm.tsx # Magic link authentication
|
||||
│ │ ├── SocialLoginForm.tsx # GitHub OAuth
|
||||
│ │ ├── NativeLoginForm.tsx # Native auth container
|
||||
│ │ ├── AppleSignIn.tsx # Apple Sign-In (iOS)
|
||||
│ │ └── MFASettings.tsx # Multi-factor authentication
|
||||
│ │
|
||||
│ └── lib/
|
||||
│ ├── nhost/
|
||||
│ │ ├── AuthProvider.tsx # Authentication context
|
||||
│ │ └── AsyncStorage.tsx # Session persistence adapter
|
||||
│ └── utils.ts # Utility functions
|
||||
│
|
||||
├── assets/ # App icons and images
|
||||
├── app.json # Expo configuration
|
||||
└── README files # Documentation (this file and others)
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
1. **AuthProvider** wraps the entire app providing global auth state
|
||||
2. **ProtectedScreen** component guards routes requiring authentication
|
||||
3. **Session persistence** maintains login state across app restarts
|
||||
4. **Deep linking** handles magic links and OAuth redirects
|
||||
|
||||
### Key Components
|
||||
|
||||
- **AuthProvider**: Central authentication state management
|
||||
- **ProtectedScreen**: Higher-order component for route protection
|
||||
- **Verification flows**: Unified handling for magic links and OAuth callbacks
|
||||
- **Storage adapter**: Custom AsyncStorage implementation for session persistence
|
||||
|
||||
### Supported Authentication Methods
|
||||
|
||||
1. **Email/Password**: Traditional username/password with MFA support
|
||||
2. **Magic Links**: Passwordless authentication via email verification
|
||||
3. **Social OAuth**: GitHub integration with redirect handling
|
||||
4. **Native Authentication**: Apple Sign-In using secure enclave
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Set these values in `app.json` under the `extra` section:
|
||||
|
||||
| Variable | Description | Example |
|
||||
| ----------------- | ---------------------------- | ------------- |
|
||||
| `NHOST_SUBDOMAIN` | Your Nhost project subdomain | `"myproject"` |
|
||||
| `NHOST_REGION` | Nhost region | `"us-east-1"` |
|
||||
|
||||
### Deep Linking Setup
|
||||
|
||||
The app is configured with the scheme `reactnativewebdemo://` for standalone builds and uses Expo's linking system for development.
|
||||
|
||||
## Development
|
||||
|
||||
### Local Nhost Backend
|
||||
|
||||
To run against a local Nhost backend:
|
||||
|
||||
1. Start Nhost CLI:
|
||||
|
||||
```bash
|
||||
nhost dev
|
||||
```
|
||||
|
||||
2. Update `app.json`:
|
||||
```json
|
||||
"extra": {
|
||||
"NHOST_REGION": "local",
|
||||
"NHOST_SUBDOMAIN": "local"
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Authentication
|
||||
|
||||
- Use the sign-in screen's tabbed interface to test different auth methods
|
||||
- Magic links work in development through proper deep link configuration
|
||||
- Social authentication requires OAuth app setup in your Nhost dashboard
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Protected Routes & Email Auth](./README_PROTECTED_ROUTES.md)
|
||||
- [Native Authentication](./README_NATIVE_AUTHENTICATION.md)
|
||||
- [Magic Links](./README_MAGIC_LINKS.md)
|
||||
- [Social Sign-In](./README_SOCIAL_SIGNIN.md)
|
||||
|
||||
## Learn More
|
||||
|
||||
- [Nhost Documentation](https://docs.nhost.io/)
|
||||
- [Expo Router Documentation](https://docs.expo.dev/router/)
|
||||
- [React Native Documentation](https://reactnative.dev/)
|
||||
- [Expo Documentation](https://docs.expo.dev/)
|
||||
410
deploy/demos/ReactNativeDemo/README_MAGIC_LINKS.md
Normal file
410
deploy/demos/ReactNativeDemo/README_MAGIC_LINKS.md
Normal file
@@ -0,0 +1,410 @@
|
||||
# Magic Links Authentication
|
||||
|
||||
This document explains how magic links (passwordless authentication) are implemented in the Nhost React Native demo, including deep linking configuration, verification endpoints, and testing strategies.
|
||||
|
||||
## Overview
|
||||
|
||||
Magic links provide a passwordless authentication method where users receive an email containing a link that automatically authenticates them when clicked. This implementation handles both Expo Go development and standalone app scenarios.
|
||||
|
||||
## How Magic Links Work
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
1. **Email Collection**: User enters their email address
|
||||
2. **Link Generation**: App requests magic link from Nhost with appropriate redirect URL
|
||||
3. **Email Delivery**: Nhost sends email with authentication link
|
||||
4. **Link Click**: User clicks link, which opens the app via deep linking
|
||||
5. **Token Extraction**: App extracts refresh token from the URL parameters
|
||||
6. **Authentication**: App uses refresh token to authenticate with Nhost
|
||||
7. **Redirect**: User is redirected to their profile upon successful authentication
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### MagicLinkForm Component
|
||||
|
||||
```typescript
|
||||
// app/components/MagicLinkForm.tsx
|
||||
export default function MagicLinkForm() {
|
||||
const [email, setEmail] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [success, setSuccess] = useState<boolean>(false);
|
||||
const { nhost } = useAuth();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Create the correct redirect URL for current environment
|
||||
const redirectUrl = Linking.createURL("verify");
|
||||
|
||||
await nhost.auth.signInPasswordlessEmail({
|
||||
email,
|
||||
options: {
|
||||
redirectTo: redirectUrl,
|
||||
},
|
||||
});
|
||||
|
||||
setSuccess(true);
|
||||
} catch (err) {
|
||||
const error = err as FetchError<ErrorResponse>;
|
||||
setError(
|
||||
`An error occurred while sending the magic link: ${error.message}`,
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ... UI implementation
|
||||
}
|
||||
```
|
||||
|
||||
### Key Features
|
||||
|
||||
1. **Environment Detection**: Automatically generates correct redirect URLs for Expo Go vs standalone
|
||||
2. **Error Handling**: Comprehensive error handling with user-friendly messages
|
||||
3. **Loading States**: Visual feedback during magic link generation
|
||||
4. **Success Feedback**: Confirmation when magic link is sent
|
||||
|
||||
## Deep Linking Configuration
|
||||
|
||||
### App Configuration
|
||||
|
||||
The app supports deep linking through custom URL schemes:
|
||||
|
||||
```json
|
||||
// app.json
|
||||
{
|
||||
"expo": {
|
||||
"scheme": "reactnativewebdemo",
|
||||
"ios": {
|
||||
"infoPlist": {
|
||||
"CFBundleURLTypes": [
|
||||
{
|
||||
"CFBundleURLSchemes": ["reactnativewebdemo"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### URL Format Differences
|
||||
|
||||
#### Standalone App
|
||||
|
||||
```
|
||||
reactnativewebdemo://verify?refreshToken=abc123...
|
||||
```
|
||||
|
||||
#### Expo Go Development
|
||||
|
||||
```
|
||||
exp://192.168.1.103:19000/--/verify?refreshToken=abc123...
|
||||
```
|
||||
|
||||
### Dynamic URL Generation
|
||||
|
||||
```typescript
|
||||
// Automatically creates correct URL format for current environment
|
||||
const redirectUrl = Linking.createURL("verify");
|
||||
|
||||
// In Expo Go: exp://192.168.1.103:19000/--/verify
|
||||
// In standalone: reactnativewebdemo://verify
|
||||
```
|
||||
|
||||
## Verification Endpoint
|
||||
|
||||
### Verify Screen Implementation
|
||||
|
||||
```typescript
|
||||
// app/verify.tsx
|
||||
export default function Verify() {
|
||||
const params = useLocalSearchParams<{ refreshToken: string }>();
|
||||
const [status, setStatus] = useState<"verifying" | "success" | "error">(
|
||||
"verifying",
|
||||
);
|
||||
const { nhost, isAuthenticated } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const refreshToken = params.refreshToken;
|
||||
|
||||
if (!refreshToken) {
|
||||
setStatus("error");
|
||||
setError("No refresh token found in the link");
|
||||
return;
|
||||
}
|
||||
|
||||
async function processToken(): Promise<void> {
|
||||
try {
|
||||
// Brief delay to show verifying state
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// Authenticate using the refresh token
|
||||
await nhost.auth.refreshToken({ refreshToken });
|
||||
|
||||
setStatus("success");
|
||||
|
||||
// Redirect to profile after brief success message
|
||||
setTimeout(() => {
|
||||
router.replace("/profile");
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
setStatus("error");
|
||||
setError(`Authentication failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
processToken();
|
||||
}, [params, nhost.auth]);
|
||||
|
||||
// ... UI implementation for different states
|
||||
}
|
||||
```
|
||||
|
||||
### Verification States
|
||||
|
||||
1. **Verifying**: Shows loading spinner while processing token
|
||||
2. **Success**: Displays success message before redirect
|
||||
3. **Error**: Shows error details and debugging information
|
||||
|
||||
## URL Parameter Handling
|
||||
|
||||
### Token Extraction
|
||||
|
||||
The verify screen extracts authentication parameters from the URL:
|
||||
|
||||
```typescript
|
||||
// Extract refresh token from URL parameters
|
||||
const params = useLocalSearchParams<{ refreshToken: string }>();
|
||||
const refreshToken = params.refreshToken;
|
||||
|
||||
// Validate token presence
|
||||
if (!refreshToken) {
|
||||
setStatus("error");
|
||||
setError("No refresh token found in the link");
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
### Debug Information
|
||||
|
||||
For development, the verify screen can display all received parameters:
|
||||
|
||||
```typescript
|
||||
// Debug: Show all URL parameters (development only)
|
||||
if (__DEV__) {
|
||||
const allParams: Record<string, string> = {};
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (typeof value === "string") {
|
||||
allParams[key] = value;
|
||||
}
|
||||
});
|
||||
console.log("Received URL parameters:", allParams);
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Magic Links
|
||||
|
||||
### Development with Expo Go
|
||||
|
||||
1. **Start Development Server**:
|
||||
|
||||
```bash
|
||||
npx expo start
|
||||
```
|
||||
|
||||
2. **Note Your Local URL**:
|
||||
|
||||
- Check terminal output for development URL (e.g., `exp://192.168.1.103:19000`)
|
||||
|
||||
3. **Send Magic Link**:
|
||||
|
||||
- Use Magic Link form in the app
|
||||
- Enter your email address
|
||||
- Submit the form
|
||||
|
||||
4. **Check Email Format**:
|
||||
|
||||
- Magic link should use format: `exp://192.168.1.103:19000/--/verify?refreshToken=...`
|
||||
- The `--` segment is crucial for Expo Go routing
|
||||
|
||||
5. **Test the Link**:
|
||||
- Open email on device with Expo Go installed
|
||||
- Tap the magic link
|
||||
- Should open directly in Expo Go
|
||||
|
||||
### Testing Strategies
|
||||
|
||||
#### Manual Testing
|
||||
|
||||
```typescript
|
||||
// Test different scenarios
|
||||
const testScenarios = [
|
||||
"Valid magic link with correct token",
|
||||
"Expired magic link",
|
||||
"Invalid refresh token",
|
||||
"Malformed URL parameters",
|
||||
"Network connectivity issues",
|
||||
"Already authenticated user",
|
||||
];
|
||||
```
|
||||
|
||||
#### Automated URL Testing
|
||||
|
||||
```typescript
|
||||
// Manually test URL handling
|
||||
const testUrls = [
|
||||
"exp://192.168.1.103:19000/--/verify?refreshToken=valid_token",
|
||||
"exp://192.168.1.103:19000/--/verify?refreshToken=invalid_token",
|
||||
"exp://192.168.1.103:19000/--/verify", // Missing token
|
||||
];
|
||||
```
|
||||
|
||||
## Environment-Specific Considerations
|
||||
|
||||
### Expo Go Limitations
|
||||
|
||||
1. **URL Format**: Must use `exp://` protocol with development server URL
|
||||
2. **Port Changes**: URL changes if development server restarts on different port
|
||||
3. **Network Dependency**: Requires same network for device and development machine
|
||||
4. **Debug Access**: Can inspect URL parameters more easily
|
||||
|
||||
### Standalone App Benefits
|
||||
|
||||
1. **Custom Scheme**: Uses app's custom URL scheme (`reactnativewebdemo://`)
|
||||
2. **Universal Links**: Can configure universal links for production
|
||||
3. **App Store Distribution**: Works with published apps
|
||||
4. **Offline Capability**: Less dependent on development server
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Token Security
|
||||
|
||||
1. **Short-Lived Tokens**: Refresh tokens have limited lifespan
|
||||
2. **Single Use**: Tokens are invalidated after successful authentication
|
||||
3. **Secure Transport**: Links are sent via secure email delivery
|
||||
4. **Validation**: Server-side token validation prevents tampering
|
||||
|
||||
### Best Practices
|
||||
|
||||
```typescript
|
||||
// Implement proper error handling
|
||||
const processToken = async (token: string) => {
|
||||
try {
|
||||
// Validate token format before sending to server
|
||||
if (!token || token.length < 10) {
|
||||
throw new Error("Invalid token format");
|
||||
}
|
||||
|
||||
// Use the token
|
||||
await nhost.auth.refreshToken({ refreshToken: token });
|
||||
} catch (error) {
|
||||
// Log error for debugging but don't expose details to user
|
||||
console.error("Magic link authentication failed:", error);
|
||||
throw new Error("Authentication failed. Please try again.");
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
| Issue | Symptom | Solution |
|
||||
| ---------------------------------- | --------------------------------------- | ------------------------------------------------------------- |
|
||||
| Link doesn't open app | Clicking link opens browser instead | Check URL scheme configuration and Expo Go installation |
|
||||
| "No refresh token" error | Link opens app but shows error | Verify email contains correct URL format with parameters |
|
||||
| Network errors during verification | Authentication fails with network error | Check Nhost configuration and internet connectivity |
|
||||
| Wrong URL format in email | Link uses incorrect protocol or format | Verify `Linking.createURL()` usage and development server URL |
|
||||
|
||||
### Debug Steps
|
||||
|
||||
1. **Check Console Logs**:
|
||||
|
||||
```typescript
|
||||
console.log("Generated redirect URL:", redirectUrl);
|
||||
console.log("Received URL parameters:", params);
|
||||
```
|
||||
|
||||
2. **Verify Email Content**:
|
||||
|
||||
- Check that email contains correct URL format
|
||||
- Ensure refresh token parameter is present
|
||||
|
||||
3. **Test URL Manually**:
|
||||
|
||||
- Copy magic link from email
|
||||
- Paste into browser or use device's URL handler
|
||||
|
||||
4. **Network Debugging**:
|
||||
- Ensure device and development machine are on same network
|
||||
- Check firewall settings
|
||||
|
||||
### Expo Go Specific Debugging
|
||||
|
||||
```typescript
|
||||
// Debug Expo Go URLs
|
||||
if (__DEV__) {
|
||||
const expoUrl = Linking.createURL("verify");
|
||||
console.log("Expo Go URL format:", expoUrl);
|
||||
|
||||
// Should output something like:
|
||||
// exp://192.168.1.103:19000/--/verify
|
||||
}
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Universal Links (iOS)
|
||||
|
||||
For production iOS apps, configure universal links:
|
||||
|
||||
```json
|
||||
// apple-app-site-association
|
||||
{
|
||||
"applinks": {
|
||||
"apps": [],
|
||||
"details": [
|
||||
{
|
||||
"appID": "TEAMID.com.nhost.reactnativewebdemo",
|
||||
"paths": ["/verify*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### App Links (Android)
|
||||
|
||||
Configure Android app links for seamless user experience:
|
||||
|
||||
```xml
|
||||
<!-- android/app/src/main/AndroidManifest.xml -->
|
||||
<activity android:name=".MainActivity">
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https"
|
||||
android:host="yourapp.com"
|
||||
android:pathPrefix="/verify" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Protected Routes & Email Auth](./README_PROTECTED_ROUTES.md)
|
||||
- [Native Authentication](./README_NATIVE_AUTHENTICATION.md)
|
||||
- [Social Sign-In](./README_SOCIAL_SIGNIN.md)
|
||||
|
||||
## External Resources
|
||||
|
||||
- [Expo Linking Documentation](https://docs.expo.dev/guides/linking/)
|
||||
- [React Navigation Deep Linking](https://reactnavigation.org/docs/deep-linking/)
|
||||
- [Nhost Passwordless Authentication](https://docs.nhost.io/authentication/passwordless)
|
||||
- [Universal Links (iOS)](https://developer.apple.com/ios/universal-links/)
|
||||
- [Android App Links](https://developer.android.com/training/app-links)
|
||||
381
deploy/demos/ReactNativeDemo/README_NATIVE_AUTHENTICATION.md
Normal file
381
deploy/demos/ReactNativeDemo/README_NATIVE_AUTHENTICATION.md
Normal file
@@ -0,0 +1,381 @@
|
||||
# Native Authentication - Apple Sign-In
|
||||
|
||||
This document explains how native authentication with Apple Sign-In is implemented in the Nhost React Native demo, including deep linking, nonce generation, ID tokens, and security considerations.
|
||||
|
||||
## Overview
|
||||
|
||||
Apple Sign-In provides a secure, privacy-focused authentication method for iOS users. The implementation uses cryptographic nonces, identity tokens, and deep linking to ensure a secure authentication flow between the app, Apple's servers, and Nhost.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
1. **Nonce Generation**: Create a cryptographic nonce for request verification
|
||||
2. **Apple Authentication**: Request user authentication from Apple
|
||||
3. **Identity Token**: Receive signed JWT from Apple containing user information
|
||||
4. **Nhost Verification**: Send identity token and nonce to Nhost for verification
|
||||
5. **Session Creation**: Nhost validates the token and creates a user session
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Apple Sign-In Component
|
||||
|
||||
```typescript
|
||||
// app/components/AppleSignIn.tsx
|
||||
const AppleSignIn: React.FC<AppleSignInProps> = ({ setIsLoading }) => {
|
||||
const { nhost } = useAuth();
|
||||
const [appleAuthAvailable, setAppleAuthAvailable] = useState(false);
|
||||
|
||||
// Check Apple authentication availability
|
||||
useEffect(() => {
|
||||
const checkAvailability = async () => {
|
||||
if (Platform.OS === "ios") {
|
||||
const isAvailable = await AppleAuthentication.isAvailableAsync();
|
||||
setAppleAuthAvailable(isAvailable);
|
||||
}
|
||||
};
|
||||
checkAvailability();
|
||||
}, []);
|
||||
|
||||
const handleAppleSignIn = async () => {
|
||||
try {
|
||||
// Generate cryptographic nonce
|
||||
const nonce = Math.random().toString(36).substring(2, 15);
|
||||
const hashedNonce = await Crypto.digestStringAsync(
|
||||
Crypto.CryptoDigestAlgorithm.SHA256,
|
||||
nonce,
|
||||
);
|
||||
|
||||
// Request Apple authentication
|
||||
const credential = await AppleAuthentication.signInAsync({
|
||||
requestedScopes: [
|
||||
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
|
||||
AppleAuthentication.AppleAuthenticationScope.EMAIL,
|
||||
],
|
||||
nonce: hashedNonce,
|
||||
});
|
||||
|
||||
// Authenticate with Nhost
|
||||
if (credential.identityToken) {
|
||||
const response = await nhost.auth.signInIdToken({
|
||||
provider: "apple",
|
||||
idToken: credential.identityToken,
|
||||
nonce, // Original unhashed nonce
|
||||
});
|
||||
|
||||
if (response.body?.session) {
|
||||
router.replace("/profile");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle authentication errors
|
||||
}
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Security Mechanisms
|
||||
|
||||
### Cryptographic Nonce
|
||||
|
||||
The nonce prevents replay attacks and ensures request authenticity:
|
||||
|
||||
```typescript
|
||||
// Generate random nonce
|
||||
const nonce = Math.random().toString(36).substring(2, 15);
|
||||
|
||||
// Hash nonce for Apple (SHA256)
|
||||
const hashedNonce = await Crypto.digestStringAsync(
|
||||
Crypto.CryptoDigestAlgorithm.SHA256,
|
||||
nonce,
|
||||
);
|
||||
|
||||
// Send hashed nonce to Apple
|
||||
const credential = await AppleAuthentication.signInAsync({
|
||||
nonce: hashedNonce,
|
||||
// ...
|
||||
});
|
||||
|
||||
// Send original nonce to Nhost for verification
|
||||
await nhost.auth.signInIdToken({
|
||||
provider: "apple",
|
||||
idToken: credential.identityToken,
|
||||
nonce, // Original unhashed nonce
|
||||
});
|
||||
```
|
||||
|
||||
### Why Nonce is Important
|
||||
|
||||
1. **Replay Attack Prevention**: Ensures each authentication request is unique
|
||||
2. **Request Binding**: Links the Apple response to the specific app request
|
||||
3. **Tampering Detection**: Detects if the response has been modified
|
||||
4. **Time-bound Security**: Nonces typically have short lifespans
|
||||
|
||||
### Identity Token Structure
|
||||
|
||||
Apple returns a JWT (JSON Web Token) containing:
|
||||
|
||||
```json
|
||||
{
|
||||
"iss": "https://appleid.apple.com",
|
||||
"aud": "com.nhost.reactnativewebdemo",
|
||||
"exp": 1634567890,
|
||||
"iat": 1634564290,
|
||||
"sub": "000123.abc456def789...",
|
||||
"nonce": "hashed_nonce_value",
|
||||
"email": "user@example.com",
|
||||
"email_verified": "true",
|
||||
"real_user_indicator": "true"
|
||||
}
|
||||
```
|
||||
|
||||
## Platform Requirements
|
||||
|
||||
### iOS Configuration
|
||||
|
||||
The app must be properly configured for Apple Sign-In:
|
||||
|
||||
```json
|
||||
// app.json
|
||||
{
|
||||
"expo": {
|
||||
"ios": {
|
||||
"bundleIdentifier": "com.nhost.reactnativewebdemo",
|
||||
"infoPlist": {
|
||||
"NSFaceIDUsageDescription": "This app uses Face ID for signing in"
|
||||
}
|
||||
},
|
||||
"plugins": ["expo-apple-authentication"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Availability Check
|
||||
|
||||
Apple Sign-In is only available on iOS 13+ devices:
|
||||
|
||||
```typescript
|
||||
const checkAvailability = async () => {
|
||||
if (Platform.OS === "ios") {
|
||||
const isAvailable = await AppleAuthentication.isAvailableAsync();
|
||||
setAppleAuthAvailable(isAvailable);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Nhost Configuration
|
||||
|
||||
### Apple Provider Setup
|
||||
|
||||
Configure Apple as an authentication provider in your Nhost dashboard:
|
||||
|
||||
1. **Team ID**: Your Apple Developer Team ID
|
||||
2. **Service ID**: Apple Services ID for your app
|
||||
3. **Key ID**: Apple Sign-In key identifier
|
||||
4. **Private Key**: Apple Sign-In private key (P8 file content)
|
||||
|
||||
### Server-Side Verification
|
||||
|
||||
Nhost performs server-side verification of the identity token:
|
||||
|
||||
1. **Signature Verification**: Validates JWT signature using Apple's public keys
|
||||
2. **Nonce Verification**: Compares hashed nonce in token with provided nonce
|
||||
3. **Audience Verification**: Ensures token is intended for your app
|
||||
4. **Expiration Check**: Validates token hasn't expired
|
||||
5. **Issuer Validation**: Confirms token comes from Apple
|
||||
|
||||
## Deep Linking Integration
|
||||
|
||||
### URL Scheme Configuration
|
||||
|
||||
The app is configured with custom URL schemes for deep linking:
|
||||
|
||||
```json
|
||||
// app.json
|
||||
{
|
||||
"expo": {
|
||||
"scheme": "reactnativewebdemo",
|
||||
"ios": {
|
||||
"infoPlist": {
|
||||
"CFBundleURLTypes": [
|
||||
{
|
||||
"CFBundleURLSchemes": ["reactnativewebdemo"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Handling Deep Links
|
||||
|
||||
While Apple Sign-In typically doesn't require custom deep linking (it's handled within the app), the configuration supports it for other authentication flows:
|
||||
|
||||
```typescript
|
||||
// app/verify.tsx - Used by other auth methods
|
||||
useEffect(() => {
|
||||
const subscription = Linking.addEventListener("url", handleDeepLink);
|
||||
return () => subscription?.remove();
|
||||
}, []);
|
||||
|
||||
const handleDeepLink = (event: { url: string }) => {
|
||||
// Handle incoming deep links from authentication providers
|
||||
};
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Common Apple Sign-In Errors
|
||||
|
||||
```typescript
|
||||
const handleAppleSignIn = async () => {
|
||||
try {
|
||||
// ... authentication logic
|
||||
} catch (error: any) {
|
||||
if (error.code === "ERR_CANCELED") {
|
||||
// User canceled authentication
|
||||
return;
|
||||
}
|
||||
|
||||
if (error.code === "ERR_INVALID_RESPONSE") {
|
||||
Alert.alert("Error", "Invalid response from Apple");
|
||||
return;
|
||||
}
|
||||
|
||||
if (error.code === "ERR_NOT_AVAILABLE") {
|
||||
Alert.alert("Error", "Apple Sign-In not available on this device");
|
||||
return;
|
||||
}
|
||||
|
||||
// Generic error handling
|
||||
Alert.alert("Authentication Error", error.message || "Unknown error");
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Nhost Integration Errors
|
||||
|
||||
```typescript
|
||||
const response = await nhost.auth.signInIdToken({
|
||||
provider: "apple",
|
||||
idToken: credential.identityToken,
|
||||
nonce,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
switch (response.error.message) {
|
||||
case "Invalid identity token":
|
||||
Alert.alert("Error", "Authentication failed. Please try again.");
|
||||
break;
|
||||
case "Invalid nonce":
|
||||
Alert.alert("Error", "Security verification failed");
|
||||
break;
|
||||
default:
|
||||
Alert.alert("Error", "Authentication error occurred");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Privacy Features
|
||||
|
||||
### Apple's Privacy Protection
|
||||
|
||||
Apple Sign-In provides enhanced privacy features:
|
||||
|
||||
1. **Email Relay**: Apple can provide relay emails to protect user's real email
|
||||
2. **Minimal Data**: Only requests necessary user information
|
||||
3. **User Control**: Users can choose what information to share
|
||||
4. **Private Email**: Option to hide real email address
|
||||
|
||||
### Handling Private Emails
|
||||
|
||||
```typescript
|
||||
// Handle Apple's private relay emails
|
||||
const credential = await AppleAuthentication.signInAsync({
|
||||
requestedScopes: [
|
||||
AppleAuthentication.AppleAuthenticationScope.EMAIL,
|
||||
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
|
||||
],
|
||||
nonce: hashedNonce,
|
||||
});
|
||||
|
||||
// Email might be a private relay address
|
||||
console.log("Email:", credential.email); // Could be privaterelay@example.com
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Development Testing
|
||||
|
||||
1. **iOS Simulator**: Apple Sign-In works in iOS Simulator (iOS 14+)
|
||||
2. **Physical Device**: Test on real iOS devices for complete functionality
|
||||
3. **Xcode Console**: Monitor authentication flow through Xcode logs
|
||||
|
||||
### Test Scenarios
|
||||
|
||||
```typescript
|
||||
// Test different authentication states
|
||||
const testScenarios = [
|
||||
"First-time sign in with Apple ID",
|
||||
"Returning user authentication",
|
||||
"User cancels authentication",
|
||||
"Network connection issues",
|
||||
"Invalid Apple ID credentials",
|
||||
"Apple ID with 2FA enabled",
|
||||
];
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### Implementation Guidelines
|
||||
|
||||
1. **Always Use Nonce**: Never skip nonce generation for production apps
|
||||
2. **Validate Server-Side**: Let Nhost handle token validation
|
||||
3. **Handle Errors Gracefully**: Provide clear feedback to users
|
||||
4. **Secure Storage**: Let Nhost handle session storage securely
|
||||
5. **Regular Updates**: Keep Apple authentication libraries updated
|
||||
|
||||
### Production Considerations
|
||||
|
||||
1. **Apple Developer Account**: Requires paid Apple Developer membership
|
||||
2. **App Store Review**: Apple Sign-In must be implemented if other social logins exist
|
||||
3. **Bundle ID Matching**: Ensure bundle ID matches Apple configuration
|
||||
4. **Certificate Management**: Keep Apple certificates and keys updated
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
| Issue | Cause | Solution |
|
||||
| ---------------------- | ---------------------------------------- | ------------------------------------------------- |
|
||||
| "Not Available" Error | iOS version < 13 or not configured | Check device compatibility and configuration |
|
||||
| Invalid Identity Token | Incorrect Nhost Apple configuration | Verify Apple provider settings in Nhost dashboard |
|
||||
| Nonce Mismatch | Sending hashed nonce to Nhost | Send original unhashed nonce to Nhost |
|
||||
| Bundle ID Mismatch | App bundle ID doesn't match Apple config | Ensure bundle IDs match in all configurations |
|
||||
|
||||
### Debug Tools
|
||||
|
||||
```typescript
|
||||
// Enable debug logging
|
||||
if (__DEV__) {
|
||||
console.log("Apple Auth Available:", appleAuthAvailable);
|
||||
console.log("Generated Nonce:", nonce);
|
||||
console.log("Hashed Nonce:", hashedNonce);
|
||||
console.log("Identity Token:", credential.identityToken);
|
||||
}
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Apple Sign-In Setup Guide](./APPLE_SIGN_IN_SETUP.md)
|
||||
- [Protected Routes & Email Auth](./README_PROTECTED_ROUTES.md)
|
||||
- [Magic Links](./README_MAGIC_LINKS.md)
|
||||
- [Social Sign-In](./README_SOCIAL_SIGNIN.md)
|
||||
|
||||
## External Resources
|
||||
|
||||
- [Apple Sign-In Documentation](https://developer.apple.com/sign-in-with-apple/)
|
||||
- [Expo Apple Authentication](https://docs.expo.dev/versions/latest/sdk/apple-authentication/)
|
||||
- [Nhost Apple Provider Setup](https://docs.nhost.io/authentication/providers/apple)
|
||||
- [JWT Token Inspector](https://jwt.io/) - For debugging identity tokens
|
||||
357
deploy/demos/ReactNativeDemo/README_PROTECTED_ROUTES.md
Normal file
357
deploy/demos/ReactNativeDemo/README_PROTECTED_ROUTES.md
Normal file
@@ -0,0 +1,357 @@
|
||||
# Protected Routes & Email Authentication
|
||||
|
||||
This document explains how protected routes and email/password authentication are implemented in the Nhost React Native demo, including multi-factor authentication (MFA) support.
|
||||
|
||||
## Overview
|
||||
|
||||
The app implements a robust authentication system with:
|
||||
|
||||
- Email/password sign-up and sign-in
|
||||
- Route protection for authenticated users
|
||||
- Multi-factor authentication (MFA) with TOTP
|
||||
- Persistent session management
|
||||
- Automatic redirects for unauthenticated users
|
||||
|
||||
## Authentication Context
|
||||
|
||||
### AuthProvider Implementation
|
||||
|
||||
The `AuthProvider` component wraps the entire app and provides global authentication state:
|
||||
|
||||
```typescript
|
||||
// app/lib/nhost/AuthProvider.tsx
|
||||
const AuthProvider = ({ children }: AuthProviderProps) => {
|
||||
const [user, setUser] = useState<Session["user"] | null>(null);
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
|
||||
|
||||
const nhost = useMemo(() => {
|
||||
const subdomain =
|
||||
Constants.expoConfig?.extra?.["NHOST_SUBDOMAIN"] || "local";
|
||||
const region = Constants.expoConfig?.extra?.["NHOST_REGION"] || "local";
|
||||
|
||||
return createClient({
|
||||
subdomain,
|
||||
region,
|
||||
storage: new NhostAsyncStorage(), // Custom AsyncStorage adapter
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Session initialization and change listeners...
|
||||
};
|
||||
```
|
||||
|
||||
### Key Features
|
||||
|
||||
1. **Session Persistence**: Uses a custom AsyncStorage adapter that works with Nhost's synchronous interface
|
||||
2. **Automatic State Updates**: Listens for session changes and updates the global state
|
||||
3. **Loading States**: Manages loading states during authentication operations
|
||||
4. **Error Handling**: Graceful handling of storage and authentication errors
|
||||
|
||||
## Protected Routes
|
||||
|
||||
### ProtectedScreen Component
|
||||
|
||||
The `ProtectedScreen` component acts as a higher-order component that protects routes:
|
||||
|
||||
```typescript
|
||||
// app/components/ProtectedScreen.tsx
|
||||
export default function ProtectedScreen({
|
||||
children,
|
||||
redirectTo = "/signin",
|
||||
}: ProtectedScreenProps) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.replace(redirectTo);
|
||||
}
|
||||
}, [isAuthenticated, isLoading, redirectTo]);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null; // Will redirect in useEffect
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
```
|
||||
|
||||
### Usage Example
|
||||
|
||||
Protect any screen by wrapping it with `ProtectedScreen`:
|
||||
|
||||
```typescript
|
||||
// app/profile.tsx
|
||||
export default function Profile() {
|
||||
return (
|
||||
<ProtectedScreen>
|
||||
<ProfileContent />
|
||||
</ProtectedScreen>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
1. **Automatic Redirects**: Unauthenticated users are redirected to sign-in
|
||||
2. **Loading States**: Shows loading indicator while checking authentication
|
||||
3. **Customizable Redirect**: Can specify where to redirect unauthenticated users
|
||||
4. **No Flash**: Prevents showing protected content before redirect
|
||||
|
||||
## Email/Password Authentication
|
||||
|
||||
### Sign Up Flow
|
||||
|
||||
```typescript
|
||||
// User registration with email and password
|
||||
const handleSignUp = async () => {
|
||||
const { error } = await nhost.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
displayName,
|
||||
},
|
||||
});
|
||||
|
||||
if (!error) {
|
||||
// User created successfully
|
||||
router.replace("/profile");
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Sign In Flow
|
||||
|
||||
```typescript
|
||||
// User authentication with email and password
|
||||
const handleSignIn = async () => {
|
||||
const { error, needsEmailVerification, needsMfaOtp } =
|
||||
await nhost.auth.signInEmailPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (needsEmailVerification) {
|
||||
setError("Please verify your email before signing in");
|
||||
return;
|
||||
}
|
||||
|
||||
if (needsMfaOtp) {
|
||||
// Redirect to MFA input screen
|
||||
setShowMfaInput(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!error) {
|
||||
router.replace("/profile");
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Multi-Factor Authentication (MFA)
|
||||
|
||||
### TOTP Setup
|
||||
|
||||
The app supports Time-based One-Time Password (TOTP) authentication:
|
||||
|
||||
```typescript
|
||||
// Generate TOTP secret and QR code
|
||||
const generateMfa = async () => {
|
||||
const { totpSecret, qrCodeDataUrl } = await nhost.auth.generateMfa();
|
||||
|
||||
// Display QR code for user to scan with authenticator app
|
||||
setQrCode(qrCodeDataUrl);
|
||||
setTotpSecret(totpSecret);
|
||||
};
|
||||
```
|
||||
|
||||
### MFA Verification
|
||||
|
||||
```typescript
|
||||
// Verify TOTP code during sign-in
|
||||
const verifyMfaCode = async () => {
|
||||
const { error } = await nhost.auth.signInMfaTotp({
|
||||
otp: mfaCode,
|
||||
});
|
||||
|
||||
if (!error) {
|
||||
router.replace("/profile");
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### MFA Management
|
||||
|
||||
Users can enable/disable MFA from their profile:
|
||||
|
||||
```typescript
|
||||
// Enable MFA with TOTP
|
||||
const enableMfa = async () => {
|
||||
const { error } = await nhost.auth.enableMfa({
|
||||
code: totpCode,
|
||||
});
|
||||
};
|
||||
|
||||
// Disable MFA
|
||||
const disableMfa = async () => {
|
||||
const { error } = await nhost.auth.disableMfa({
|
||||
code: totpCode,
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
## Session Management
|
||||
|
||||
### Custom AsyncStorage Adapter
|
||||
|
||||
The app uses a custom storage adapter for reliable session persistence:
|
||||
|
||||
```typescript
|
||||
// app/lib/nhost/AsyncStorage.tsx
|
||||
export default class NhostAsyncStorage implements Storage {
|
||||
private cache: Map<string, string> = new Map();
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this.cache.set(key, value);
|
||||
AsyncStorage.setItem(key, value).catch(console.error);
|
||||
}
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.cache.get(key) || null;
|
||||
}
|
||||
|
||||
removeItem(key: string): void {
|
||||
this.cache.delete(key);
|
||||
AsyncStorage.removeItem(key).catch(console.error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
1. **In-Memory Cache**: Provides synchronous access for Nhost while using AsyncStorage
|
||||
2. **Persistence**: Sessions survive app restarts and background/foreground cycles
|
||||
3. **Error Handling**: Graceful fallback if AsyncStorage operations fail
|
||||
4. **Expo Go Compatible**: Works reliably in both Expo Go and standalone builds
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Common Authentication Errors
|
||||
|
||||
```typescript
|
||||
const handleAuthError = (error: any) => {
|
||||
switch (error?.message) {
|
||||
case "Invalid email or password":
|
||||
setError("Please check your email and password");
|
||||
break;
|
||||
case "Email not verified":
|
||||
setError("Please verify your email before signing in");
|
||||
break;
|
||||
case "Invalid MFA code":
|
||||
setError("Please enter a valid 6-digit code");
|
||||
break;
|
||||
default:
|
||||
setError("An unexpected error occurred");
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Network and Storage Errors
|
||||
|
||||
The app handles various error scenarios:
|
||||
|
||||
- Network connectivity issues
|
||||
- AsyncStorage failures
|
||||
- Nhost service unavailability
|
||||
- Invalid authentication tokens
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Best Practices Implemented
|
||||
|
||||
1. **Secure Storage**: Sessions are stored securely using AsyncStorage
|
||||
2. **Token Validation**: Automatic token refresh and validation
|
||||
3. **Route Protection**: Server-side validation of protected routes
|
||||
4. **MFA Support**: Additional security layer with TOTP
|
||||
5. **Session Expiry**: Automatic logout when sessions expire
|
||||
|
||||
### Password Requirements
|
||||
|
||||
Configure password requirements in your Nhost dashboard:
|
||||
|
||||
- Minimum length
|
||||
- Character complexity
|
||||
- Common password prevention
|
||||
- Breach database checking
|
||||
|
||||
## Testing Authentication
|
||||
|
||||
### Test Scenarios
|
||||
|
||||
1. **Valid Credentials**: Test successful sign-in with correct email/password
|
||||
2. **Invalid Credentials**: Test error handling with wrong credentials
|
||||
3. **Unverified Email**: Test flow for users who haven't verified email
|
||||
4. **MFA Flow**: Test sign-in with MFA enabled
|
||||
5. **Session Persistence**: Test app restart with active session
|
||||
6. **Network Errors**: Test offline scenarios and poor connectivity
|
||||
|
||||
### Debug Tools
|
||||
|
||||
Enable debug mode to see authentication state changes:
|
||||
|
||||
```typescript
|
||||
// Add to AuthProvider for debugging
|
||||
useEffect(() => {
|
||||
if (__DEV__) {
|
||||
console.log("Auth state changed:", { isAuthenticated, user: user?.email });
|
||||
}
|
||||
}, [isAuthenticated, user]);
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Required Setup
|
||||
|
||||
1. **Email Provider**: Configure email provider in Nhost dashboard
|
||||
2. **Email Templates**: Customize verification and welcome emails
|
||||
3. **Password Policy**: Set password requirements
|
||||
4. **MFA Settings**: Enable TOTP in authentication settings
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```json
|
||||
// app.json
|
||||
{
|
||||
"extra": {
|
||||
"NHOST_SUBDOMAIN": "your-project-subdomain",
|
||||
"NHOST_REGION": "your-region"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Session Not Persisting**: Check AsyncStorage permissions and implementation
|
||||
2. **Infinite Loading**: Verify Nhost configuration and network connectivity
|
||||
3. **MFA Not Working**: Ensure time synchronization between device and server
|
||||
4. **Redirect Loops**: Check protected route logic and authentication state
|
||||
|
||||
### Debug Steps
|
||||
|
||||
1. Check console logs for authentication errors
|
||||
2. Verify Nhost dashboard configuration
|
||||
3. Test with simple email/password flow first
|
||||
4. Gradually add complexity (MFA, protected routes)
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Native Authentication](./README_NATIVE_AUTHENTICATION.md)
|
||||
- [Magic Links](./README_MAGIC_LINKS.md)
|
||||
- [Social Sign-In](./README_SOCIAL_SIGNIN.md)
|
||||
530
deploy/demos/ReactNativeDemo/README_SOCIAL_SIGNIN.md
Normal file
530
deploy/demos/ReactNativeDemo/README_SOCIAL_SIGNIN.md
Normal file
@@ -0,0 +1,530 @@
|
||||
# Social Sign-In with GitHub
|
||||
|
||||
This document explains how social authentication with GitHub is implemented in the Nhost React Native demo, including OAuth flow, deep linking, verification endpoints, and configuration requirements.
|
||||
|
||||
## Overview
|
||||
|
||||
Social sign-in with GitHub provides users with a seamless authentication experience using their existing GitHub accounts. The implementation handles OAuth 2.0 flow, deep linking for mobile apps, and secure token exchange through Nhost's authentication system.
|
||||
|
||||
## OAuth 2.0 Flow
|
||||
|
||||
### Authentication Process
|
||||
|
||||
1. **OAuth Initiation**: App redirects user to GitHub OAuth page
|
||||
2. **User Authorization**: User grants permissions to the app on GitHub
|
||||
3. **Authorization Code**: GitHub redirects back with authorization code
|
||||
4. **Token Exchange**: Nhost exchanges code for access token
|
||||
5. **User Profile**: Nhost fetches user profile from GitHub
|
||||
6. **Session Creation**: Nhost creates authenticated session for the user
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### SocialLoginForm Component
|
||||
|
||||
```typescript
|
||||
// app/components/SocialLoginForm.tsx
|
||||
export default function SocialLoginForm({
|
||||
action,
|
||||
isLoading: initialLoading = false,
|
||||
}: SocialLoginFormProps) {
|
||||
const { nhost } = useAuth();
|
||||
const [isLoading] = useState(initialLoading);
|
||||
|
||||
const handleSocialLogin = (provider: "github") => {
|
||||
// Create redirect URL for current environment
|
||||
const redirectUrl = Linking.createURL("verify");
|
||||
|
||||
// Generate OAuth URL with provider and redirect
|
||||
const url = nhost.auth.signInProviderURL(provider, {
|
||||
redirectTo: redirectUrl,
|
||||
});
|
||||
|
||||
// Open GitHub OAuth in system browser
|
||||
void Linking.openURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.socialContainer}>
|
||||
<Text style={styles.socialText}>
|
||||
{action} using your Social account
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.socialButton}
|
||||
onPress={() => handleSocialLogin("github")}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<View style={styles.buttonContent}>
|
||||
<Ionicons name="logo-github" size={22} style={styles.githubIcon} />
|
||||
<Text style={styles.socialButtonText}>Continue with GitHub</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Key Features
|
||||
|
||||
1. **Dynamic URL Generation**: Automatically creates correct redirect URLs for different environments
|
||||
2. **Provider Flexibility**: Easily extensible to support additional OAuth providers
|
||||
3. **Visual Feedback**: Loading states and branded buttons for better UX
|
||||
4. **Error Handling**: Graceful handling of OAuth failures and cancellations
|
||||
|
||||
## Deep Linking Configuration
|
||||
|
||||
### URL Scheme Setup
|
||||
|
||||
The app supports deep linking to handle OAuth redirects:
|
||||
|
||||
```json
|
||||
// app.json
|
||||
{
|
||||
"expo": {
|
||||
"scheme": "reactnativewebdemo",
|
||||
"ios": {
|
||||
"infoPlist": {
|
||||
"CFBundleURLTypes": [
|
||||
{
|
||||
"CFBundleURLSchemes": ["reactnativewebdemo"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Redirect URL Formats
|
||||
|
||||
#### Standalone App
|
||||
|
||||
```
|
||||
reactnativewebdemo://verify?refreshToken=abc123...&type=signup
|
||||
```
|
||||
|
||||
#### Expo Go Development
|
||||
|
||||
```
|
||||
exp://192.168.1.103:19000/--/verify?refreshToken=abc123...&type=signup
|
||||
```
|
||||
|
||||
### Environment-Aware URL Generation
|
||||
|
||||
```typescript
|
||||
// Automatically handles environment differences
|
||||
const redirectUrl = Linking.createURL("verify");
|
||||
|
||||
// Creates appropriate URL for current environment:
|
||||
// - Expo Go: exp://host:port/--/verify
|
||||
// - Standalone: reactnativewebdemo://verify
|
||||
```
|
||||
|
||||
## GitHub OAuth Configuration
|
||||
|
||||
### Nhost Dashboard Setup
|
||||
|
||||
Configure GitHub as an OAuth provider in your Nhost dashboard:
|
||||
|
||||
1. **Provider**: Enable GitHub in Authentication > Providers
|
||||
2. **Client ID**: GitHub OAuth App Client ID
|
||||
3. **Client Secret**: GitHub OAuth App Client Secret
|
||||
4. **Redirect URL**: Configure allowed redirect URLs
|
||||
|
||||
### GitHub OAuth App Setup
|
||||
|
||||
Create a GitHub OAuth App in your GitHub Developer Settings:
|
||||
|
||||
1. **Application Name**: Your app name
|
||||
2. **Homepage URL**: Your app's homepage
|
||||
3. **Authorization Callback URL**:
|
||||
- Development: `https://local.auth.nhost.run/v1/auth/providers/github/callback`
|
||||
- Production: `https://[subdomain].auth.[region].nhost.run/v1/auth/providers/github/callback`
|
||||
|
||||
### Required GitHub Scopes
|
||||
|
||||
The app requests these GitHub scopes:
|
||||
|
||||
- `user:email` - Access to user's email addresses
|
||||
- `read:user` - Access to user profile information
|
||||
|
||||
## Verification Flow
|
||||
|
||||
### Verify Screen Implementation
|
||||
|
||||
```typescript
|
||||
// app/verify.tsx - Handles both magic links and social auth
|
||||
export default function Verify() {
|
||||
const params = useLocalSearchParams<{
|
||||
refreshToken: string;
|
||||
type?: string;
|
||||
}>();
|
||||
const [status, setStatus] = useState<"verifying" | "success" | "error">(
|
||||
"verifying",
|
||||
);
|
||||
const { nhost, isAuthenticated } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const { refreshToken, type } = params;
|
||||
|
||||
if (!refreshToken) {
|
||||
setStatus("error");
|
||||
setError("No authentication token found");
|
||||
return;
|
||||
}
|
||||
|
||||
async function processAuthentication(): Promise<void> {
|
||||
try {
|
||||
// Show verifying state briefly
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// Authenticate using refresh token from OAuth flow
|
||||
await nhost.auth.refreshToken({ refreshToken });
|
||||
|
||||
setStatus("success");
|
||||
|
||||
// Redirect after showing success message
|
||||
setTimeout(() => {
|
||||
router.replace("/profile");
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
setStatus("error");
|
||||
setError(`Authentication failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
processAuthentication();
|
||||
}, [params, nhost.auth]);
|
||||
|
||||
// Redirect if already authenticated
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && status !== "verifying") {
|
||||
router.replace("/profile");
|
||||
}
|
||||
}, [isAuthenticated, status]);
|
||||
|
||||
// ... UI implementation for different states
|
||||
}
|
||||
```
|
||||
|
||||
### Authentication States
|
||||
|
||||
1. **Verifying**: Processing OAuth callback and exchanging tokens
|
||||
2. **Success**: Authentication completed successfully
|
||||
3. **Error**: OAuth flow failed or was cancelled
|
||||
|
||||
## User Data Handling
|
||||
|
||||
### GitHub Profile Information
|
||||
|
||||
When users authenticate with GitHub, Nhost receives:
|
||||
|
||||
```typescript
|
||||
// User profile data from GitHub
|
||||
interface GitHubUser {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
metadata: {
|
||||
github: {
|
||||
id: number;
|
||||
login: string;
|
||||
name: string;
|
||||
company?: string;
|
||||
blog?: string;
|
||||
location?: string;
|
||||
bio?: string;
|
||||
public_repos: number;
|
||||
followers: number;
|
||||
following: number;
|
||||
created_at: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Accessing User Data
|
||||
|
||||
```typescript
|
||||
// Access GitHub-specific user data
|
||||
const { user } = useAuth();
|
||||
|
||||
if (user?.metadata?.github) {
|
||||
const githubData = user.metadata.github;
|
||||
console.log("GitHub username:", githubData.login);
|
||||
console.log("Public repos:", githubData.public_repos);
|
||||
console.log("Followers:", githubData.followers);
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### OAuth Flow Errors
|
||||
|
||||
```typescript
|
||||
// Common OAuth error scenarios
|
||||
const handleOAuthErrors = (error: any) => {
|
||||
switch (error.type) {
|
||||
case "access_denied":
|
||||
// User denied permission
|
||||
Alert.alert("Access Denied", "You need to grant permission to continue");
|
||||
break;
|
||||
|
||||
case "invalid_request":
|
||||
// Malformed OAuth request
|
||||
Alert.alert("Error", "Invalid authentication request");
|
||||
break;
|
||||
|
||||
case "server_error":
|
||||
// GitHub server error
|
||||
Alert.alert("Error", "GitHub is temporarily unavailable");
|
||||
break;
|
||||
|
||||
case "temporarily_unavailable":
|
||||
// Service temporarily unavailable
|
||||
Alert.alert("Error", "Authentication service is busy. Please try again.");
|
||||
break;
|
||||
|
||||
default:
|
||||
Alert.alert("Error", "Authentication failed. Please try again.");
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Network and Integration Errors
|
||||
|
||||
```typescript
|
||||
// Handle Nhost integration errors
|
||||
const processOAuthCallback = async (refreshToken: string) => {
|
||||
try {
|
||||
await nhost.auth.refreshToken({ refreshToken });
|
||||
} catch (error) {
|
||||
if (error.message.includes("Invalid refresh token")) {
|
||||
throw new Error("Authentication session expired. Please try again.");
|
||||
}
|
||||
|
||||
if (error.message.includes("Network")) {
|
||||
throw new Error("Network error. Please check your connection.");
|
||||
}
|
||||
|
||||
throw new Error("Authentication failed. Please try again.");
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Testing Social Authentication
|
||||
|
||||
### Development Testing
|
||||
|
||||
1. **Start Development Server**:
|
||||
|
||||
```bash
|
||||
npx expo start
|
||||
```
|
||||
|
||||
2. **Configure Test Environment**:
|
||||
|
||||
- Ensure GitHub OAuth app has correct callback URL
|
||||
- Verify Nhost GitHub provider configuration
|
||||
- Check network connectivity between device and development server
|
||||
|
||||
3. **Test OAuth Flow**:
|
||||
- Tap "Continue with GitHub" button
|
||||
- Should open system browser with GitHub OAuth page
|
||||
- Log in with GitHub credentials
|
||||
- Grant permissions to the app
|
||||
- Should redirect back to app and authenticate
|
||||
|
||||
### Test Scenarios
|
||||
|
||||
```typescript
|
||||
// Test different OAuth scenarios
|
||||
const testScenarios = [
|
||||
"First-time GitHub authentication",
|
||||
"Returning GitHub user",
|
||||
"User cancels OAuth flow",
|
||||
"User denies permissions",
|
||||
"GitHub account with 2FA enabled",
|
||||
"Network connection issues during OAuth",
|
||||
"Invalid OAuth configuration",
|
||||
"Expired OAuth session",
|
||||
];
|
||||
```
|
||||
|
||||
### Manual Testing Checklist
|
||||
|
||||
- [ ] GitHub OAuth button appears and is clickable
|
||||
- [ ] Clicking button opens system browser
|
||||
- [ ] GitHub login page loads correctly
|
||||
- [ ] Successfully logging in redirects back to app
|
||||
- [ ] App shows verification screen briefly
|
||||
- [ ] User is redirected to profile after authentication
|
||||
- [ ] User data is correctly populated from GitHub
|
||||
- [ ] Canceling OAuth flow handles gracefully
|
||||
- [ ] Network errors are handled appropriately
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### OAuth Security Best Practices
|
||||
|
||||
1. **HTTPS Only**: All OAuth URLs use HTTPS for secure communication
|
||||
2. **State Parameter**: Nhost includes state parameter to prevent CSRF attacks
|
||||
3. **Short-Lived Tokens**: Authorization codes have short expiration times
|
||||
4. **Secure Storage**: Refresh tokens are stored securely by Nhost
|
||||
5. **Scope Limitation**: Only request necessary permissions from GitHub
|
||||
|
||||
### Token Security
|
||||
|
||||
```typescript
|
||||
// Nhost handles secure token management
|
||||
// - Authorization codes are exchanged server-side
|
||||
// - Access tokens are not exposed to client
|
||||
// - Refresh tokens are securely stored
|
||||
// - Session management is handled automatically
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
| Issue | Symptom | Solution |
|
||||
| ---------------------------- | --------------------------------------- | ---------------------------------------------------------------- |
|
||||
| OAuth redirect doesn't work | Browser opens but doesn't return to app | Check URL scheme configuration and GitHub OAuth app callback URL |
|
||||
| "Invalid client" error | GitHub shows OAuth error page | Verify GitHub OAuth app Client ID in Nhost dashboard |
|
||||
| "Redirect URI mismatch" | GitHub rejects OAuth request | Ensure callback URL in GitHub app matches Nhost configuration |
|
||||
| App doesn't open after OAuth | Browser stays open after GitHub login | Check deep linking configuration and app installation |
|
||||
|
||||
### Debug Steps
|
||||
|
||||
1. **Check OAuth URL**:
|
||||
|
||||
```typescript
|
||||
const url = nhost.auth.signInProviderURL("github", {
|
||||
redirectTo: redirectUrl,
|
||||
});
|
||||
console.log("OAuth URL:", url);
|
||||
```
|
||||
|
||||
2. **Verify Redirect URL**:
|
||||
|
||||
```typescript
|
||||
const redirectUrl = Linking.createURL("verify");
|
||||
console.log("Redirect URL:", redirectUrl);
|
||||
```
|
||||
|
||||
3. **Check URL Parameters**:
|
||||
|
||||
```typescript
|
||||
// In verify screen
|
||||
console.log("Received parameters:", params);
|
||||
```
|
||||
|
||||
4. **Test Manual URL**:
|
||||
- Copy OAuth URL from console
|
||||
- Paste into browser to test flow manually
|
||||
|
||||
### GitHub-Specific Debugging
|
||||
|
||||
1. **Check GitHub OAuth App Settings**:
|
||||
|
||||
- Verify callback URLs are correctly configured
|
||||
- Ensure app is not suspended or restricted
|
||||
|
||||
2. **Monitor GitHub OAuth Logs**:
|
||||
|
||||
- Check GitHub OAuth app's activity logs
|
||||
- Look for failed authorization attempts
|
||||
|
||||
3. **Validate GitHub Scopes**:
|
||||
- Ensure requested scopes match app requirements
|
||||
- Check if user has granted necessary permissions
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### GitHub OAuth App Configuration
|
||||
|
||||
For production deployment:
|
||||
|
||||
1. **Production Callback URL**:
|
||||
|
||||
```
|
||||
https://[subdomain].auth.[region].nhost.run/v1/auth/providers/github/callback
|
||||
```
|
||||
|
||||
2. **Homepage URL**: Set to your production app's homepage
|
||||
|
||||
3. **Application Description**: Provide clear description of your app's purpose
|
||||
|
||||
### Universal Links Setup
|
||||
|
||||
Configure universal links for seamless production experience:
|
||||
|
||||
```json
|
||||
// apple-app-site-association (iOS)
|
||||
{
|
||||
"applinks": {
|
||||
"apps": [],
|
||||
"details": [
|
||||
{
|
||||
"appID": "TEAMID.com.nhost.reactnativewebdemo",
|
||||
"paths": ["/verify*", "/auth/callback*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Security Hardening
|
||||
|
||||
1. **Environment Variables**: Store sensitive OAuth credentials securely
|
||||
2. **Domain Validation**: Implement additional domain validation for callbacks
|
||||
3. **Rate Limiting**: Configure rate limiting for OAuth endpoints
|
||||
4. **Monitoring**: Set up monitoring for failed OAuth attempts
|
||||
|
||||
## Extending to Other Providers
|
||||
|
||||
### Adding New OAuth Providers
|
||||
|
||||
The implementation can be easily extended to support other providers:
|
||||
|
||||
```typescript
|
||||
// Extended provider support
|
||||
type SocialProvider = "github" | "google" | "facebook" | "discord";
|
||||
|
||||
const handleSocialLogin = (provider: SocialProvider) => {
|
||||
const redirectUrl = Linking.createURL("verify");
|
||||
const url = nhost.auth.signInProviderURL(provider, {
|
||||
redirectTo: redirectUrl,
|
||||
});
|
||||
void Linking.openURL(url);
|
||||
};
|
||||
|
||||
// Provider-specific UI
|
||||
const getProviderIcon = (provider: SocialProvider) => {
|
||||
switch (provider) {
|
||||
case "github":
|
||||
return "logo-github";
|
||||
case "google":
|
||||
return "logo-google";
|
||||
case "facebook":
|
||||
return "logo-facebook";
|
||||
case "discord":
|
||||
return "logo-discord";
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Protected Routes & Email Auth](./README_PROTECTED_ROUTES.md)
|
||||
- [Native Authentication](./README_NATIVE_AUTHENTICATION.md)
|
||||
- [Magic Links](./README_MAGIC_LINKS.md)
|
||||
|
||||
## External Resources
|
||||
|
||||
- [GitHub OAuth Documentation](https://docs.github.com/en/developers/apps/building-oauth-apps)
|
||||
- [Nhost Social Authentication](https://docs.nhost.io/authentication/social-login)
|
||||
- [OAuth 2.0 Security Best Practices](https://tools.ietf.org/html/draft-ietf-oauth-security-topics)
|
||||
- [Expo AuthSession](https://docs.expo.dev/versions/latest/sdk/auth-session/)
|
||||
- [React Native Deep Linking](https://reactnative.dev/docs/linking)
|
||||
53
deploy/demos/ReactNativeDemo/app.json
Normal file
53
deploy/demos/ReactNativeDemo/app.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "ReactNativeWebDemo",
|
||||
"slug": "ReactNativeWebDemo",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "reactnativewebdemo",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.nhost.reactnativewebdemo",
|
||||
"jsEngine": "jsc",
|
||||
"infoPlist": {
|
||||
"NSFaceIDUsageDescription": "This app uses Face ID for signing in",
|
||||
"CFBundleURLTypes": [
|
||||
{
|
||||
"CFBundleURLSchemes": ["reactnativewebdemo"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "jsc",
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"edgeToEdgeEnabled": true
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"imageWidth": 200,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
}
|
||||
],
|
||||
["expo-apple-authentication"]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
},
|
||||
"extra": {
|
||||
"NHOST_REGION": "local",
|
||||
"NHOST_SUBDOMAIN": "192-168-1-103"
|
||||
}
|
||||
}
|
||||
}
|
||||
54
deploy/demos/ReactNativeDemo/app/_layout.tsx
Normal file
54
deploy/demos/ReactNativeDemo/app/_layout.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { Text, View } from "react-native";
|
||||
import { AuthProvider } from "./lib/nhost/AuthProvider";
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: {
|
||||
backgroundColor: "#f5f5f5",
|
||||
},
|
||||
headerTintColor: "#333",
|
||||
headerTitleStyle: {
|
||||
fontWeight: "bold",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" options={{ title: "Home" }} />
|
||||
<Stack.Screen name="signin" options={{ title: "Sign In" }} />
|
||||
<Stack.Screen
|
||||
name="signin/mfa"
|
||||
options={{ title: "MFA Verification" }}
|
||||
/>
|
||||
<Stack.Screen name="signup" options={{ title: "Sign Up" }} />
|
||||
<Stack.Screen name="profile" options={{ title: "Profile" }} />
|
||||
<Stack.Screen name="upload" options={{ title: "File Upload" }} />
|
||||
<Stack.Screen name="verify" options={{ title: "Verify Email" }} />
|
||||
</Stack>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Error boundary to catch and display errors
|
||||
export function ErrorBoundary(props: { error: Error }) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: 20,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 18, fontWeight: "bold", marginBottom: 10 }}>
|
||||
An error occurred
|
||||
</Text>
|
||||
<Text style={{ color: "red", marginBottom: 10 }}>
|
||||
{props.error.message}
|
||||
</Text>
|
||||
<Text>{props.error.stack}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
112
deploy/demos/ReactNativeDemo/app/components/AppleSignIn.tsx
Normal file
112
deploy/demos/ReactNativeDemo/app/components/AppleSignIn.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import * as AppleAuthentication from "expo-apple-authentication";
|
||||
import * as Crypto from "expo-crypto";
|
||||
import { router } from "expo-router";
|
||||
import React from "react";
|
||||
import { Alert, Platform, StyleSheet } from "react-native";
|
||||
import { useAuth } from "../lib/nhost/AuthProvider";
|
||||
|
||||
interface AppleSignInProps {
|
||||
action: "Sign In" | "Sign Up";
|
||||
isLoading: boolean;
|
||||
setIsLoading: (isLoading: boolean) => void;
|
||||
}
|
||||
|
||||
const AppleSignIn: React.FC<AppleSignInProps> = ({ setIsLoading }) => {
|
||||
const { nhost } = useAuth();
|
||||
|
||||
// Check if Apple authentication is available on this device
|
||||
const [appleAuthAvailable, setAppleAuthAvailable] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const checkAvailability = async () => {
|
||||
if (Platform.OS === "ios") {
|
||||
const isAvailable = await AppleAuthentication.isAvailableAsync();
|
||||
setAppleAuthAvailable(isAvailable);
|
||||
}
|
||||
};
|
||||
|
||||
void checkAvailability();
|
||||
}, []);
|
||||
|
||||
const handleAppleSignIn = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const nonce = Math.random().toString(36).substring(2, 15);
|
||||
|
||||
// Hash the nonce for Apple Authentication
|
||||
const hashedNonce = await Crypto.digestStringAsync(
|
||||
Crypto.CryptoDigestAlgorithm.SHA256,
|
||||
nonce,
|
||||
);
|
||||
|
||||
// Request Apple authentication with our hashed nonce
|
||||
const credential = await AppleAuthentication.signInAsync({
|
||||
requestedScopes: [
|
||||
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
|
||||
AppleAuthentication.AppleAuthenticationScope.EMAIL,
|
||||
],
|
||||
nonce: hashedNonce,
|
||||
});
|
||||
|
||||
if (credential.identityToken) {
|
||||
// Use the identity token to sign in with Nhost
|
||||
// Pass the original unhashed nonce to the SDK
|
||||
// so the server can verify it
|
||||
const response = await nhost.auth.signInIdToken({
|
||||
provider: "apple",
|
||||
idToken: credential.identityToken,
|
||||
nonce,
|
||||
});
|
||||
|
||||
if (response.body?.session) {
|
||||
router.replace("/profile");
|
||||
} else {
|
||||
Alert.alert(
|
||||
"Authentication Error",
|
||||
"Failed to authenticate with Nhost",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
Alert.alert(
|
||||
"Authentication Error",
|
||||
"No identity token received from Apple",
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// Handle other errors
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to authentica with Apple";
|
||||
Alert.alert("Authentication Error", message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Only show the button on iOS devices where Apple authentication is available
|
||||
if (Platform.OS !== "ios" || !appleAuthAvailable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AppleAuthentication.AppleAuthenticationButton
|
||||
buttonType={AppleAuthentication.AppleAuthenticationButtonType.SIGN_IN}
|
||||
buttonStyle={AppleAuthentication.AppleAuthenticationButtonStyle.BLACK}
|
||||
cornerRadius={5}
|
||||
style={styles.appleButton}
|
||||
onPress={handleAppleSignIn}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
appleButton: {
|
||||
width: "100%",
|
||||
height: 45,
|
||||
marginBottom: 10,
|
||||
},
|
||||
});
|
||||
|
||||
export default AppleSignIn;
|
||||
594
deploy/demos/ReactNativeDemo/app/components/MFASettings.tsx
Normal file
594
deploy/demos/ReactNativeDemo/app/components/MFASettings.tsx
Normal file
@@ -0,0 +1,594 @@
|
||||
import type { ErrorResponse } from "@nhost/nhost-js/auth";
|
||||
import type { FetchError } from "@nhost/nhost-js/fetch";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Dimensions,
|
||||
Image,
|
||||
Keyboard,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useAuth } from "../lib/nhost/AuthProvider";
|
||||
|
||||
interface MFASettingsProps {
|
||||
initialMfaEnabled: boolean;
|
||||
}
|
||||
|
||||
export default function MFASettings({ initialMfaEnabled }: MFASettingsProps) {
|
||||
const [isMfaEnabled, setIsMfaEnabled] = useState<boolean>(initialMfaEnabled);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const { nhost } = useAuth();
|
||||
|
||||
// Update internal state when prop changes
|
||||
useEffect(() => {
|
||||
if (initialMfaEnabled !== isMfaEnabled) {
|
||||
setIsMfaEnabled(initialMfaEnabled);
|
||||
}
|
||||
}, [initialMfaEnabled, isMfaEnabled]);
|
||||
|
||||
// MFA setup states
|
||||
const [isSettingUpMfa, setIsSettingUpMfa] = useState<boolean>(false);
|
||||
const [totpSecret, setTotpSecret] = useState<string>("");
|
||||
const [qrCodeUrl, setQrCodeUrl] = useState<string>("");
|
||||
const [verificationCode, setVerificationCode] = useState<string>("");
|
||||
const [qrCodeModalVisible, setQrCodeModalVisible] = useState<boolean>(false);
|
||||
|
||||
// Disabling MFA states
|
||||
const [isDisablingMfa, setIsDisablingMfa] = useState<boolean>(false);
|
||||
const [disableVerificationCode, setDisableVerificationCode] =
|
||||
useState<string>("");
|
||||
|
||||
// Begin MFA setup process
|
||||
const handleEnableMfa = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
// Generate TOTP secret
|
||||
const response = await nhost.auth.changeUserMfa();
|
||||
setTotpSecret(response.body.totpSecret);
|
||||
setQrCodeUrl(response.body.imageUrl);
|
||||
setIsSettingUpMfa(true);
|
||||
} catch (err) {
|
||||
const errMessage = err instanceof Error ? err.message : "Unknown error";
|
||||
setError(`An error occurred while enabling MFA: ${errMessage}`);
|
||||
Alert.alert("Error", `Failed to enable MFA: ${errMessage}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Verify TOTP and enable MFA
|
||||
const handleVerifyTotp = async () => {
|
||||
if (!verificationCode) {
|
||||
setError("Please enter the verification code");
|
||||
Alert.alert("Error", "Please enter the verification code");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
// Verify and activate MFA
|
||||
await nhost.auth.verifyChangeUserMfa({
|
||||
activeMfaType: "totp",
|
||||
code: verificationCode,
|
||||
});
|
||||
|
||||
setIsMfaEnabled(true);
|
||||
setIsSettingUpMfa(false);
|
||||
setSuccess("MFA has been successfully enabled.");
|
||||
Alert.alert("Success", "MFA has been successfully enabled.");
|
||||
} catch (err) {
|
||||
const errMessage = err instanceof Error ? err.message : "Unknown error";
|
||||
setError(`An error occurred while verifying the code: ${errMessage}`);
|
||||
Alert.alert("Error", `Failed to verify code: ${errMessage}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Show disable MFA confirmation
|
||||
const handleShowDisableMfa = () => {
|
||||
setIsDisablingMfa(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
};
|
||||
|
||||
// Disable MFA
|
||||
const handleDisableMfa = async () => {
|
||||
if (!disableVerificationCode) {
|
||||
setError("Please enter your verification code to confirm");
|
||||
Alert.alert("Error", "Please enter your verification code to confirm");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
// Disable MFA by setting activeMfaType to empty string
|
||||
await nhost.auth.verifyChangeUserMfa({
|
||||
activeMfaType: "",
|
||||
code: disableVerificationCode,
|
||||
});
|
||||
|
||||
setIsMfaEnabled(false);
|
||||
setIsDisablingMfa(false);
|
||||
setDisableVerificationCode("");
|
||||
setSuccess("MFA has been successfully disabled.");
|
||||
Alert.alert("Success", "MFA has been successfully disabled.");
|
||||
} catch (err) {
|
||||
const error = err as FetchError<ErrorResponse>;
|
||||
setError(`An error occurred while disabling MFA: ${error.message}`);
|
||||
Alert.alert("Error", `Failed to disable MFA: ${error.message}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Cancel MFA setup
|
||||
const handleCancelMfaSetup = () => {
|
||||
setIsSettingUpMfa(false);
|
||||
setTotpSecret("");
|
||||
setQrCodeUrl("");
|
||||
setVerificationCode("");
|
||||
};
|
||||
|
||||
// Cancel MFA disable
|
||||
const handleCancelMfaDisable = () => {
|
||||
setIsDisablingMfa(false);
|
||||
setDisableVerificationCode("");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Multi-Factor Authentication</Text>
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<View style={styles.successContainer}>
|
||||
<Text style={styles.successText}>{success}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isSettingUpMfa ? (
|
||||
<KeyboardAvoidingView behavior="padding" style={{ flex: 1 }}>
|
||||
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
>
|
||||
<Text style={styles.instructionText}>
|
||||
Scan this QR code with your authenticator app (e.g., Google
|
||||
Authenticator, Authy):
|
||||
</Text>
|
||||
|
||||
{qrCodeUrl && (
|
||||
<TouchableOpacity
|
||||
style={styles.qrCodeContainer}
|
||||
onPress={() => setQrCodeModalVisible(true)}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: qrCodeUrl }}
|
||||
style={styles.qrCode}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<Text style={styles.copyHint}>(Tap to enlarge)</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
animationType="slide"
|
||||
transparent={true}
|
||||
visible={qrCodeModalVisible}
|
||||
onRequestClose={() => setQrCodeModalVisible(false)}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.modalContent}>
|
||||
<Text style={styles.modalTitle}>Scan QR Code</Text>
|
||||
<Image
|
||||
source={{ uri: qrCodeUrl }}
|
||||
style={styles.largeQrCode}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={styles.closeButton}
|
||||
onPress={() => setQrCodeModalVisible(false)}
|
||||
>
|
||||
<Text style={styles.closeButtonText}>Close</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
<Text style={styles.instructionText}>
|
||||
Or manually enter this secret key:
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.secretContainer}
|
||||
onPress={async () => {
|
||||
await Clipboard.setStringAsync(totpSecret);
|
||||
Alert.alert("Copied", "Secret key copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<Text style={styles.secretText}>{totpSecret}</Text>
|
||||
<Text style={styles.copyHint}>(Tap to copy)</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Verification Code</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={verificationCode}
|
||||
onChangeText={setVerificationCode}
|
||||
placeholder="Enter 6-digit code"
|
||||
maxLength={6}
|
||||
keyboardType="number-pad"
|
||||
returnKeyType="done"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={[styles.buttonRow, { marginBottom: 30 }]}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
styles.primaryButton,
|
||||
(!verificationCode || isLoading) && styles.disabledButton,
|
||||
]}
|
||||
onPress={handleVerifyTotp}
|
||||
disabled={isLoading || !verificationCode}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Verify and Enable</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.secondaryButton]}
|
||||
onPress={handleCancelMfaSetup}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</TouchableWithoutFeedback>
|
||||
</KeyboardAvoidingView>
|
||||
) : isDisablingMfa ? (
|
||||
<KeyboardAvoidingView behavior="padding" style={{ flex: 1 }}>
|
||||
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
>
|
||||
<Text style={styles.instructionText}>
|
||||
To disable Multi-Factor Authentication, please enter the current
|
||||
verification code from your authenticator app.
|
||||
</Text>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Current Verification Code</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={disableVerificationCode}
|
||||
onChangeText={setDisableVerificationCode}
|
||||
placeholder="Enter 6-digit code"
|
||||
maxLength={6}
|
||||
keyboardType="number-pad"
|
||||
returnKeyType="done"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={[styles.buttonRow, { marginBottom: 30 }]}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
styles.primaryButton,
|
||||
(!disableVerificationCode || isLoading) &&
|
||||
styles.disabledButton,
|
||||
]}
|
||||
onPress={handleDisableMfa}
|
||||
disabled={isLoading || !disableVerificationCode}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Confirm Disable</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.secondaryButton]}
|
||||
onPress={handleCancelMfaDisable}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</TouchableWithoutFeedback>
|
||||
</KeyboardAvoidingView>
|
||||
) : (
|
||||
<View style={styles.contentContainer}>
|
||||
<Text style={styles.instructionText}>
|
||||
Multi-Factor Authentication adds an extra layer of security to your
|
||||
account by requiring a verification code from your authenticator app
|
||||
when signing in.
|
||||
</Text>
|
||||
|
||||
<View style={styles.statusContainer}>
|
||||
<Text style={styles.statusLabel}>Status:</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.statusValue,
|
||||
isMfaEnabled ? styles.enabledText : styles.disabledText,
|
||||
]}
|
||||
>
|
||||
{isMfaEnabled ? "Enabled" : "Disabled"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{isMfaEnabled ? (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
styles.secondaryButton,
|
||||
isLoading && styles.disabledButton,
|
||||
]}
|
||||
onPress={handleShowDisableMfa}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="small" color="#6366f1" />
|
||||
) : (
|
||||
<Text style={styles.secondaryButtonText}>Disable MFA</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
styles.primaryButton,
|
||||
isLoading && styles.disabledButton,
|
||||
]}
|
||||
onPress={handleEnableMfa}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Enable MFA</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 10,
|
||||
padding: 16,
|
||||
marginBottom: 20,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 16,
|
||||
color: "#333",
|
||||
},
|
||||
contentContainer: {
|
||||
marginTop: 10,
|
||||
},
|
||||
errorContainer: {
|
||||
backgroundColor: "#fee2e2",
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
marginBottom: 16,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: "#ef4444",
|
||||
},
|
||||
errorText: {
|
||||
color: "#b91c1c",
|
||||
fontSize: 14,
|
||||
},
|
||||
successContainer: {
|
||||
backgroundColor: "#dcfce7",
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
marginBottom: 16,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: "#10b981",
|
||||
},
|
||||
successText: {
|
||||
color: "#047857",
|
||||
fontSize: 14,
|
||||
},
|
||||
instructionText: {
|
||||
fontSize: 14,
|
||||
color: "#4b5563",
|
||||
marginBottom: 16,
|
||||
},
|
||||
qrCodeContainer: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#fff",
|
||||
padding: 10,
|
||||
marginVertical: 16,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: "#e5e7eb",
|
||||
},
|
||||
qrCode: {
|
||||
width: 200,
|
||||
height: 200,
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0,0,0,0.5)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
modalContent: {
|
||||
backgroundColor: "white",
|
||||
borderRadius: 12,
|
||||
padding: 20,
|
||||
alignItems: "center",
|
||||
elevation: 5,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 3.84,
|
||||
width: Dimensions.get("window").width * 0.9,
|
||||
},
|
||||
largeQrCode: {
|
||||
width: Dimensions.get("window").width * 0.7,
|
||||
height: Dimensions.get("window").width * 0.7,
|
||||
marginVertical: 20,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 15,
|
||||
},
|
||||
closeButton: {
|
||||
backgroundColor: "#6366f1",
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 30,
|
||||
borderRadius: 6,
|
||||
},
|
||||
closeButtonText: {
|
||||
color: "white",
|
||||
fontWeight: "600",
|
||||
},
|
||||
secretContainer: {
|
||||
backgroundColor: "#f3f4f6",
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
marginBottom: 16,
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
borderColor: "#d1d5db",
|
||||
borderStyle: "dashed",
|
||||
},
|
||||
secretText: {
|
||||
fontFamily: "monospace",
|
||||
fontSize: 14,
|
||||
color: "#111827",
|
||||
marginBottom: 4,
|
||||
},
|
||||
copyHint: {
|
||||
fontSize: 12,
|
||||
color: "#6366f1",
|
||||
fontStyle: "italic",
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: "500",
|
||||
marginBottom: 8,
|
||||
color: "#374151",
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: "#d1d5db",
|
||||
borderRadius: 6,
|
||||
padding: 10,
|
||||
fontSize: 16,
|
||||
backgroundColor: "#f9fafb",
|
||||
},
|
||||
buttonRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginTop: 8,
|
||||
},
|
||||
button: {
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
primaryButton: {
|
||||
backgroundColor: "#6366f1",
|
||||
marginRight: 8,
|
||||
},
|
||||
secondaryButton: {
|
||||
backgroundColor: "#f3f4f6",
|
||||
marginLeft: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: "#d1d5db",
|
||||
},
|
||||
disabledButton: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
buttonText: {
|
||||
color: "#fff",
|
||||
fontWeight: "600",
|
||||
fontSize: 14,
|
||||
},
|
||||
secondaryButtonText: {
|
||||
color: "#4b5563",
|
||||
fontWeight: "600",
|
||||
fontSize: 14,
|
||||
},
|
||||
statusContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginBottom: 20,
|
||||
},
|
||||
statusLabel: {
|
||||
fontSize: 14,
|
||||
color: "#4b5563",
|
||||
marginRight: 8,
|
||||
},
|
||||
statusValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
},
|
||||
enabledText: {
|
||||
color: "#10b981",
|
||||
},
|
||||
disabledText: {
|
||||
color: "#f59e0b",
|
||||
},
|
||||
});
|
||||
158
deploy/demos/ReactNativeDemo/app/components/MagicLinkForm.tsx
Normal file
158
deploy/demos/ReactNativeDemo/app/components/MagicLinkForm.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import type { ErrorResponse } from "@nhost/nhost-js/auth";
|
||||
import type { FetchError } from "@nhost/nhost-js/fetch";
|
||||
import * as Linking from "expo-linking";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useAuth } from "../lib/nhost/AuthProvider";
|
||||
|
||||
interface MagicLinkFormProps {
|
||||
buttonLabel?: string;
|
||||
}
|
||||
|
||||
export default function MagicLinkForm({
|
||||
buttonLabel = "Send Magic Link",
|
||||
}: MagicLinkFormProps) {
|
||||
const [email, setEmail] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [success, setSuccess] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { nhost } = useAuth();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// For Expo Go, we need to create the correct URL format
|
||||
// This will work both in Expo Go and standalone app
|
||||
const redirectUrl = Linking.createURL("verify");
|
||||
|
||||
await nhost.auth.signInPasswordlessEmail({
|
||||
email,
|
||||
options: {
|
||||
redirectTo: redirectUrl,
|
||||
},
|
||||
});
|
||||
|
||||
setSuccess(true);
|
||||
} catch (err) {
|
||||
const error = err as FetchError<ErrorResponse>;
|
||||
setError(
|
||||
`An error occurred while sending the magic link: ${error.message}`,
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.successText}>
|
||||
Magic link sent! Check your email to sign in.
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.secondaryButton}
|
||||
onPress={() => setSuccess(false)}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>Try again</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Email</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
placeholder="Enter your email"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={handleSubmit}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>{buttonLabel}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: "100%",
|
||||
},
|
||||
inputGroup: {
|
||||
marginBottom: 15,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
marginBottom: 5,
|
||||
color: "#333",
|
||||
},
|
||||
input: {
|
||||
height: 45,
|
||||
borderWidth: 1,
|
||||
borderColor: "#ddd",
|
||||
borderRadius: 5,
|
||||
paddingHorizontal: 10,
|
||||
fontSize: 16,
|
||||
backgroundColor: "#fafafa",
|
||||
},
|
||||
errorText: {
|
||||
color: "#e53e3e",
|
||||
marginBottom: 10,
|
||||
},
|
||||
successText: {
|
||||
fontSize: 16,
|
||||
color: "#38a169",
|
||||
textAlign: "center",
|
||||
marginBottom: 15,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#6366f1",
|
||||
paddingVertical: 12,
|
||||
borderRadius: 5,
|
||||
alignItems: "center",
|
||||
marginTop: 10,
|
||||
},
|
||||
buttonText: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
secondaryButton: {
|
||||
backgroundColor: "#e2e8f0",
|
||||
paddingVertical: 12,
|
||||
borderRadius: 5,
|
||||
alignItems: "center",
|
||||
marginTop: 10,
|
||||
},
|
||||
secondaryButtonText: {
|
||||
color: "#4a5568",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import AppleSignIn from "./AppleSignIn";
|
||||
|
||||
interface NativeLoginFormProps {
|
||||
action: "Sign In" | "Sign Up";
|
||||
isLoading: boolean;
|
||||
setAppleAuthInProgress: (inProgress: boolean) => void;
|
||||
}
|
||||
|
||||
export default function NativeLoginForm({
|
||||
action,
|
||||
isLoading,
|
||||
setAppleAuthInProgress,
|
||||
}: NativeLoginFormProps) {
|
||||
// Function to update loading state
|
||||
const updateLoadingState = (loading: boolean) => {
|
||||
if (setAppleAuthInProgress) {
|
||||
setAppleAuthInProgress(loading);
|
||||
}
|
||||
};
|
||||
|
||||
// Check if we have any native options for this platform
|
||||
const hasAppleOption = Platform.OS === "ios";
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.text}>
|
||||
{action} using native authentication methods
|
||||
</Text>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="large" color="#6366f1" />
|
||||
) : (
|
||||
<View style={styles.buttonContainer}>
|
||||
<AppleSignIn
|
||||
action={action}
|
||||
isLoading={isLoading}
|
||||
setIsLoading={updateLoadingState}
|
||||
/>
|
||||
|
||||
{!hasAppleOption && (
|
||||
<Text style={styles.noOptionsText}>
|
||||
No native authentication options available for your platform
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{hasAppleOption && (
|
||||
<Text style={styles.infoText}>
|
||||
Native sign-in methods provide a more streamlined authentication
|
||||
experience
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: "center",
|
||||
paddingVertical: 10,
|
||||
width: "100%",
|
||||
},
|
||||
text: {
|
||||
fontSize: 16,
|
||||
marginBottom: 20,
|
||||
textAlign: "center",
|
||||
color: "#4a5568",
|
||||
},
|
||||
buttonContainer: {
|
||||
width: "100%",
|
||||
alignItems: "center",
|
||||
},
|
||||
infoText: {
|
||||
marginTop: 10,
|
||||
fontSize: 12,
|
||||
color: "#718096",
|
||||
textAlign: "center",
|
||||
},
|
||||
noOptionsText: {
|
||||
marginTop: 20,
|
||||
fontSize: 14,
|
||||
color: "#a0aec0",
|
||||
textAlign: "center",
|
||||
fontStyle: "italic",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { router } from "expo-router";
|
||||
import type React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { ActivityIndicator, Text, View } from "react-native";
|
||||
import { useAuth } from "../lib/nhost/AuthProvider";
|
||||
|
||||
type AppRoutes = "/" | "/signin" | "/signup" | "/profile";
|
||||
|
||||
interface ProtectedScreenProps {
|
||||
children: React.ReactNode;
|
||||
redirectTo?: AppRoutes;
|
||||
}
|
||||
|
||||
export default function ProtectedScreen({
|
||||
children,
|
||||
redirectTo = "/signin",
|
||||
}: ProtectedScreenProps) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.replace(redirectTo);
|
||||
}
|
||||
}, [isAuthenticated, isLoading, redirectTo]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||
<ActivityIndicator size="large" color="#0000ff" />
|
||||
<Text style={{ marginTop: 10 }}>Loading...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null; // Will redirect in useEffect
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import * as Linking from "expo-linking";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useAuth } from "../lib/nhost/AuthProvider";
|
||||
|
||||
interface SocialLoginFormProps {
|
||||
action: "Sign In" | "Sign Up";
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export default function SocialLoginForm({
|
||||
action,
|
||||
isLoading: initialLoading = false,
|
||||
}: SocialLoginFormProps) {
|
||||
const { nhost } = useAuth();
|
||||
const [isLoading] = useState(initialLoading);
|
||||
|
||||
const handleSocialLogin = (provider: "github") => {
|
||||
// Use the same redirect URL approach as the magic link
|
||||
const redirectUrl = Linking.createURL("verify");
|
||||
|
||||
// Sign in with the specified provider
|
||||
const url = nhost.auth.signInProviderURL(provider, {
|
||||
redirectTo: redirectUrl,
|
||||
});
|
||||
|
||||
// Open the URL in browser
|
||||
void Linking.openURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.socialContainer}>
|
||||
<Text style={styles.socialText}>{action} using your Social account</Text>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="large" color="#6366f1" />
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
style={styles.socialButton}
|
||||
onPress={() => handleSocialLogin("github")}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<View style={styles.buttonContent}>
|
||||
<Ionicons name="logo-github" size={22} style={styles.githubIcon} />
|
||||
<Text style={styles.socialButtonText}>Continue with GitHub</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
socialContainer: {
|
||||
alignItems: "center",
|
||||
paddingVertical: 10,
|
||||
},
|
||||
socialText: {
|
||||
fontSize: 16,
|
||||
marginBottom: 20,
|
||||
textAlign: "center",
|
||||
color: "#4a5568",
|
||||
},
|
||||
socialButton: {
|
||||
backgroundColor: "#24292e",
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 15,
|
||||
borderRadius: 5,
|
||||
width: "100%",
|
||||
},
|
||||
buttonContent: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
githubIcon: {
|
||||
marginRight: 10,
|
||||
color: "#ffffff",
|
||||
},
|
||||
socialButtonText: {
|
||||
color: "#ffffff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
120
deploy/demos/ReactNativeDemo/app/index.tsx
Normal file
120
deploy/demos/ReactNativeDemo/app/index.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useRouter } from "expo-router";
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
||||
import { useAuth } from "./lib/nhost/AuthProvider";
|
||||
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Nhost SDK Demo</Text>
|
||||
<Text style={styles.subtitle}>React Native Example</Text>
|
||||
|
||||
<View style={styles.contentContainer}>
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<Text style={styles.welcomeText}>
|
||||
Welcome back, {user?.displayName || user?.email || "User"}!
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={() => router.push("/profile")}
|
||||
>
|
||||
<Text style={styles.buttonText}>Go to Profile</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.secondaryButton]}
|
||||
onPress={() => router.push("/upload")}
|
||||
>
|
||||
<Text style={styles.buttonText}>File Upload</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
) : (
|
||||
<View style={styles.authButtons}>
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={() => router.push("/signin")}
|
||||
>
|
||||
<Text style={styles.buttonText}>Sign In</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.secondaryButton]}
|
||||
onPress={() => router.push("/signup")}
|
||||
>
|
||||
<Text style={styles.buttonText}>Sign Up</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: 20,
|
||||
backgroundColor: "#f5f5f5",
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 8,
|
||||
color: "#333",
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 18,
|
||||
marginBottom: 30,
|
||||
color: "#666",
|
||||
},
|
||||
contentContainer: {
|
||||
width: "100%",
|
||||
maxWidth: 400,
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 10,
|
||||
padding: 20,
|
||||
alignItems: "center",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
welcomeText: {
|
||||
fontSize: 18,
|
||||
marginBottom: 20,
|
||||
textAlign: "center",
|
||||
},
|
||||
buttonContainer: {
|
||||
width: "100%",
|
||||
gap: 15,
|
||||
},
|
||||
authButtons: {
|
||||
width: "100%",
|
||||
gap: 15,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#6366f1",
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 8,
|
||||
width: "100%",
|
||||
alignItems: "center",
|
||||
marginTop: 10,
|
||||
},
|
||||
secondaryButton: {
|
||||
backgroundColor: "#818cf8",
|
||||
},
|
||||
buttonText: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
93
deploy/demos/ReactNativeDemo/app/lib/nhost/AsyncStorage.tsx
Normal file
93
deploy/demos/ReactNativeDemo/app/lib/nhost/AsyncStorage.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
DEFAULT_SESSION_KEY,
|
||||
type Session,
|
||||
type SessionStorageBackend,
|
||||
} from "@nhost/nhost-js/session";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
|
||||
/**
|
||||
* Custom storage implementation for React Native using AsyncStorage
|
||||
* to persist the Nhost session on the device.
|
||||
*
|
||||
* This implementation synchronously works with the SessionStorageBackend interface
|
||||
* while ensuring reliable persistence with AsyncStorage for Expo Go.
|
||||
*/
|
||||
export default class NhostAsyncStorage implements SessionStorageBackend {
|
||||
private key: string;
|
||||
private cache: Session | null = null;
|
||||
|
||||
constructor(key: string = DEFAULT_SESSION_KEY) {
|
||||
this.key = key;
|
||||
|
||||
// Immediately try to load from AsyncStorage
|
||||
this.loadFromAsyncStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the session from AsyncStorage synchronously if possible
|
||||
*/
|
||||
private loadFromAsyncStorage(): void {
|
||||
// Try to get cached data from AsyncStorage immediately
|
||||
try {
|
||||
AsyncStorage.getItem(this.key)
|
||||
.then((value) => {
|
||||
if (value) {
|
||||
try {
|
||||
this.cache = JSON.parse(value) as Session;
|
||||
} catch (error) {
|
||||
console.warn("Error parsing session from AsyncStorage:", error);
|
||||
this.cache = null;
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("Error loading from AsyncStorage:", error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("AsyncStorage access error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the session from the in-memory cache
|
||||
*/
|
||||
get(): Session | null {
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the session in the in-memory cache and persists to AsyncStorage
|
||||
* Ensures the data gets written by using an immediately invoked async function
|
||||
*/
|
||||
set(value: Session): void {
|
||||
// Update cache immediately
|
||||
this.cache = value;
|
||||
|
||||
// Persist to AsyncStorage with better error handling
|
||||
void (async () => {
|
||||
try {
|
||||
await AsyncStorage.setItem(this.key, JSON.stringify(value));
|
||||
} catch (error) {
|
||||
console.warn("Error saving session to AsyncStorage:", error);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the session from the in-memory cache and AsyncStorage
|
||||
* Ensures the data gets removed by using an immediately invoked async function
|
||||
*/
|
||||
remove(): void {
|
||||
// Clear cache immediately
|
||||
this.cache = null;
|
||||
|
||||
// Remove from AsyncStorage with better error handling
|
||||
void (async () => {
|
||||
try {
|
||||
await AsyncStorage.removeItem(this.key);
|
||||
} catch (error) {
|
||||
console.warn("Error removing session from AsyncStorage:", error);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
110
deploy/demos/ReactNativeDemo/app/lib/nhost/AuthProvider.tsx
Normal file
110
deploy/demos/ReactNativeDemo/app/lib/nhost/AuthProvider.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { createClient, type NhostClient } from "@nhost/nhost-js";
|
||||
import type { Session } from "@nhost/nhost-js/session";
|
||||
import Constants from "expo-constants";
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import NhostAsyncStorage from "./AsyncStorage";
|
||||
|
||||
interface AuthContextType {
|
||||
user: Session["user"] | null;
|
||||
session: Session | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
nhost: NhostClient;
|
||||
}
|
||||
|
||||
// Create context for authentication state and nhost client
|
||||
const AuthContext = createContext<AuthContextType | null>(null);
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const AuthProvider = ({ children }: AuthProviderProps) => {
|
||||
const [user, setUser] = useState<Session["user"] | null>(null);
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
|
||||
|
||||
// Create the nhost client with persistent storage
|
||||
const nhost = useMemo(() => {
|
||||
// Get configuration values with type assertion
|
||||
const subdomain =
|
||||
(Constants.expoConfig?.extra?.["NHOST_SUBDOMAIN"] as string) ||
|
||||
"192-168-1-103";
|
||||
const region =
|
||||
(Constants.expoConfig?.extra?.["NHOST_REGION"] as string) || "local";
|
||||
|
||||
return createClient({
|
||||
subdomain,
|
||||
region,
|
||||
storage: new NhostAsyncStorage(),
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize authentication state
|
||||
setIsLoading(true);
|
||||
|
||||
// Allow enough time for AsyncStorage to be read and session to be restored
|
||||
const initializeSession = async () => {
|
||||
try {
|
||||
// Let's wait a bit to ensure AsyncStorage has been read
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Now try to get the current session
|
||||
const currentSession = nhost.getUserSession();
|
||||
|
||||
setUser(currentSession?.user || null);
|
||||
setSession(currentSession);
|
||||
setIsAuthenticated(!!currentSession);
|
||||
} catch (error) {
|
||||
console.warn("Error initializing session:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void initializeSession();
|
||||
|
||||
// Listen for session changes
|
||||
const unsubscribe = nhost.sessionStorage.onChange((currentSession) => {
|
||||
setUser(currentSession?.user || null);
|
||||
setSession(currentSession);
|
||||
setIsAuthenticated(!!currentSession);
|
||||
});
|
||||
|
||||
// Clean up subscription on unmount
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [nhost]);
|
||||
|
||||
// Context value with nhost client directly exposed
|
||||
const value: AuthContextType = {
|
||||
user,
|
||||
session,
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
nhost,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
// Custom hook to use the auth context
|
||||
export const useAuth = (): AuthContextType => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export default AuthProvider;
|
||||
44
deploy/demos/ReactNativeDemo/app/lib/utils.ts
Normal file
44
deploy/demos/ReactNativeDemo/app/lib/utils.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Formats a file size in bytes to a human-readable string
|
||||
* @param bytes File size in bytes
|
||||
* @param decimals Number of decimal places to show
|
||||
* @returns Formatted file size string (e.g., "1.23 MB")
|
||||
*/
|
||||
export function formatFileSize(bytes: number, decimals = 2): string {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default export to satisfy the Router's requirements
|
||||
* This utilities file primarily exports helper functions
|
||||
*/
|
||||
export default {
|
||||
formatFileSize,
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a Blob to a Base64 string
|
||||
* @param blob The Blob object to convert
|
||||
* @returns A Promise that resolves to the Base64 string representation of the Blob
|
||||
*/
|
||||
export function blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const base64data = reader.result as string;
|
||||
// Remove the data URL prefix (e.g., "data:application/octet-stream;base64,")
|
||||
const base64Content = base64data.split(",")[1] || "";
|
||||
resolve(base64Content);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
262
deploy/demos/ReactNativeDemo/app/profile.tsx
Normal file
262
deploy/demos/ReactNativeDemo/app/profile.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import { router } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import MFASettings from "./components/MFASettings";
|
||||
import ProtectedScreen from "./components/ProtectedScreen";
|
||||
import { useAuth } from "./lib/nhost/AuthProvider";
|
||||
|
||||
interface MfaStatusResponse {
|
||||
user?: {
|
||||
activeMfaType: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export default function Profile() {
|
||||
const { nhost, user, session, isAuthenticated } = useAuth();
|
||||
const [isMfaEnabled, setIsMfaEnabled] = useState<boolean>(false);
|
||||
|
||||
// Fetch MFA status when user is authenticated
|
||||
useEffect(() => {
|
||||
const fetchMfaStatus = async () => {
|
||||
if (!user?.id) return;
|
||||
|
||||
try {
|
||||
// Correctly structure GraphQL query with parameters
|
||||
const response = await nhost.graphql.request<MfaStatusResponse>({
|
||||
query: `
|
||||
query GetUserMfaStatus($userId: uuid!) {
|
||||
user(id: $userId) {
|
||||
activeMfaType
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
const activeMfaType = response.body?.data?.user?.activeMfaType;
|
||||
const newMfaEnabled = activeMfaType === "totp";
|
||||
|
||||
// Update the state
|
||||
setIsMfaEnabled(newMfaEnabled);
|
||||
} catch (err) {
|
||||
const errMessage =
|
||||
err instanceof Error ? err.message : "An unexpected error occurred";
|
||||
console.error(`Failed to query MFA status: ${errMessage}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (isAuthenticated && user?.id) {
|
||||
void fetchMfaStatus();
|
||||
}
|
||||
}, [user, isAuthenticated, nhost.graphql]);
|
||||
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
const session = nhost.getUserSession();
|
||||
if (session) {
|
||||
await nhost.auth.signOut({
|
||||
refreshToken: session.refreshToken,
|
||||
});
|
||||
}
|
||||
|
||||
router.replace("/signin");
|
||||
} catch {
|
||||
Alert.alert("Error", "Failed to sign out");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ProtectedScreen>
|
||||
<ScrollView
|
||||
style={styles.container}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
>
|
||||
<Text style={styles.title}>Your Profile</Text>
|
||||
|
||||
<View style={styles.card}>
|
||||
<View style={styles.profileItem}>
|
||||
<Text style={styles.itemLabel}>Display Name:</Text>
|
||||
<Text style={styles.itemValue}>
|
||||
{user?.displayName || "Not set"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.profileItem}>
|
||||
<Text style={styles.itemLabel}>Email:</Text>
|
||||
<Text style={styles.itemValue}>
|
||||
{user?.email || "Not available"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.profileItem}>
|
||||
<Text style={styles.itemLabel}>User ID:</Text>
|
||||
<Text
|
||||
style={styles.itemValue}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="middle"
|
||||
>
|
||||
{user?.id || "Not available"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.profileItem}>
|
||||
<Text style={styles.itemLabel}>Roles:</Text>
|
||||
<Text style={styles.itemValue}>
|
||||
{user?.roles?.join(", ") || "None"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.profileItem}>
|
||||
<Text style={styles.itemLabel}>Email Verified:</Text>
|
||||
<Text style={styles.itemValue}>
|
||||
{user?.emailVerified ? "Yes" : "No"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.sectionTitle}>Session Information</Text>
|
||||
<View style={styles.sessionInfo}>
|
||||
<Text style={styles.sessionText}>Refresh Token ID:</Text>
|
||||
<Text
|
||||
style={styles.sessionValue}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="middle"
|
||||
>
|
||||
{session?.refreshTokenId || "None"}
|
||||
</Text>
|
||||
|
||||
<Text style={styles.sessionText}>Access Token Expires In:</Text>
|
||||
<Text style={styles.sessionValue}>
|
||||
{session?.accessTokenExpiresIn
|
||||
? `${session.accessTokenExpiresIn}s`
|
||||
: "N/A"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<MFASettings
|
||||
key={`mfa-settings-${isMfaEnabled}`}
|
||||
initialMfaEnabled={isMfaEnabled}
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={() => router.push("/upload")}
|
||||
>
|
||||
<Text style={styles.actionButtonText}>File Upload</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.signOutButton} onPress={handleSignOut}>
|
||||
<Text style={styles.signOutButtonText}>Sign Out</Text>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
</ProtectedScreen>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#f5f5f5",
|
||||
},
|
||||
contentContainer: {
|
||||
padding: 20,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 20,
|
||||
color: "#333",
|
||||
textAlign: "center",
|
||||
},
|
||||
card: {
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 10,
|
||||
padding: 16,
|
||||
marginBottom: 20,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
profileItem: {
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#f0f0f0",
|
||||
},
|
||||
itemLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: "bold",
|
||||
color: "#333",
|
||||
marginBottom: 4,
|
||||
},
|
||||
itemValue: {
|
||||
fontSize: 16,
|
||||
color: "#666",
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 12,
|
||||
color: "#333",
|
||||
},
|
||||
sessionInfo: {
|
||||
backgroundColor: "#f8f8f8",
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
},
|
||||
sessionText: {
|
||||
fontSize: 14,
|
||||
fontWeight: "500",
|
||||
color: "#333",
|
||||
marginBottom: 2,
|
||||
},
|
||||
sessionValue: {
|
||||
fontSize: 14,
|
||||
color: "#666",
|
||||
marginBottom: 10,
|
||||
fontFamily: "monospace",
|
||||
},
|
||||
actionButton: {
|
||||
backgroundColor: "#6366f1",
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 8,
|
||||
alignItems: "center",
|
||||
marginTop: 10,
|
||||
marginBottom: 10,
|
||||
},
|
||||
actionButtonText: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
signOutButton: {
|
||||
backgroundColor: "#e53e3e",
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 8,
|
||||
alignItems: "center",
|
||||
marginTop: 10,
|
||||
},
|
||||
signOutButtonText: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
367
deploy/demos/ReactNativeDemo/app/signin.tsx
Normal file
367
deploy/demos/ReactNativeDemo/app/signin.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
import { Link, router, useLocalSearchParams } from "expo-router";
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import MagicLinkForm from "./components/MagicLinkForm";
|
||||
import NativeLoginForm from "./components/NativeLoginForm";
|
||||
import SocialLoginForm from "./components/SocialLoginForm";
|
||||
import { useAuth } from "./lib/nhost/AuthProvider";
|
||||
|
||||
export default function SignIn() {
|
||||
const { nhost, isAuthenticated } = useAuth();
|
||||
const params = useLocalSearchParams();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [appleAuthInProgress, setAppleAuthInProgress] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"password" | "magic" | "social" | "native"
|
||||
>("password");
|
||||
|
||||
const magicLinkSent = params["magic"] === "success";
|
||||
|
||||
// If already authenticated, redirect to profile
|
||||
React.useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
router.replace("/profile");
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Use the signIn function from auth context
|
||||
const response = await nhost.auth.signInEmailPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
// Check if MFA is required
|
||||
if (response.body?.mfa) {
|
||||
router.push(`/signin/mfa?ticket=${response.body.mfa.ticket}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we have a session, sign in was successful
|
||||
if (response.body?.session) {
|
||||
router.replace("/profile");
|
||||
} else {
|
||||
setError("Failed to sign in");
|
||||
}
|
||||
} catch (err) {
|
||||
const errMessage =
|
||||
err instanceof Error ? err.message : "An unexpected error occurred";
|
||||
setError(`An error occurred during sign in: ${errMessage}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView behavior="padding" style={styles.container}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContainer}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Text style={styles.title}>Nhost SDK Demo</Text>
|
||||
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>Sign In</Text>
|
||||
|
||||
{magicLinkSent ? (
|
||||
<View style={styles.messageContainer}>
|
||||
<Text style={styles.successText}>
|
||||
Magic link sent! Check your email to sign in.
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.secondaryButton}
|
||||
onPress={() => router.setParams({ magic: "" })}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>Back to sign in</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.tabContainer}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.tabButton,
|
||||
activeTab === "password" && styles.activeTab,
|
||||
]}
|
||||
onPress={() => setActiveTab("password")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "password" && styles.activeTabText,
|
||||
]}
|
||||
>
|
||||
Password
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.tabButton,
|
||||
activeTab === "magic" && styles.activeTab,
|
||||
]}
|
||||
onPress={() => setActiveTab("magic")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "magic" && styles.activeTabText,
|
||||
]}
|
||||
>
|
||||
Magic Link
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.tabButton,
|
||||
activeTab === "social" && styles.activeTab,
|
||||
]}
|
||||
onPress={() => setActiveTab("social")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "social" && styles.activeTabText,
|
||||
]}
|
||||
>
|
||||
Social
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.tabButton,
|
||||
activeTab === "native" && styles.activeTab,
|
||||
]}
|
||||
onPress={() => setActiveTab("native")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "native" && styles.activeTabText,
|
||||
]}
|
||||
>
|
||||
Native
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.form}>
|
||||
{activeTab === "password" ? (
|
||||
<>
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Email</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
placeholder="Enter your email"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Password</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
placeholder="Enter your password"
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={handleSubmit}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Sign In</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
) : activeTab === "magic" ? (
|
||||
<MagicLinkForm buttonLabel="Sign In with Magic Link" />
|
||||
) : activeTab === "social" ? (
|
||||
<SocialLoginForm action="Sign In" isLoading={isLoading} />
|
||||
) : (
|
||||
<NativeLoginForm
|
||||
action="Sign In"
|
||||
isLoading={isLoading || appleAuthInProgress}
|
||||
setAppleAuthInProgress={setAppleAuthInProgress}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>
|
||||
Don't have an account?{" "}
|
||||
<Link href="/signup" style={styles.link}>
|
||||
Sign Up
|
||||
</Link>
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#f5f5f5",
|
||||
},
|
||||
scrollContainer: {
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
padding: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: "bold",
|
||||
textAlign: "center",
|
||||
marginBottom: 20,
|
||||
color: "#333",
|
||||
},
|
||||
card: {
|
||||
width: "100%",
|
||||
maxWidth: 400,
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 10,
|
||||
padding: 20,
|
||||
alignSelf: "center",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 20,
|
||||
textAlign: "center",
|
||||
},
|
||||
tabContainer: {
|
||||
flexDirection: "row",
|
||||
marginBottom: 20,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#e2e8f0",
|
||||
},
|
||||
tabButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
alignItems: "center",
|
||||
},
|
||||
tabText: {
|
||||
fontSize: 16,
|
||||
color: "#718096",
|
||||
},
|
||||
activeTab: {
|
||||
borderBottomWidth: 2,
|
||||
borderBottomColor: "#6366f1",
|
||||
},
|
||||
activeTabText: {
|
||||
color: "#6366f1",
|
||||
fontWeight: "600",
|
||||
},
|
||||
form: {
|
||||
width: "100%",
|
||||
},
|
||||
inputGroup: {
|
||||
marginBottom: 15,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
marginBottom: 5,
|
||||
color: "#333",
|
||||
},
|
||||
input: {
|
||||
height: 45,
|
||||
borderWidth: 1,
|
||||
borderColor: "#ddd",
|
||||
borderRadius: 5,
|
||||
paddingHorizontal: 10,
|
||||
fontSize: 16,
|
||||
backgroundColor: "#fafafa",
|
||||
},
|
||||
errorText: {
|
||||
color: "#e53e3e",
|
||||
marginBottom: 10,
|
||||
},
|
||||
successText: {
|
||||
color: "#38a169",
|
||||
fontSize: 16,
|
||||
textAlign: "center",
|
||||
marginBottom: 15,
|
||||
},
|
||||
messageContainer: {
|
||||
alignItems: "center",
|
||||
paddingVertical: 10,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#6366f1",
|
||||
paddingVertical: 12,
|
||||
borderRadius: 5,
|
||||
alignItems: "center",
|
||||
marginTop: 10,
|
||||
},
|
||||
buttonText: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
secondaryButton: {
|
||||
backgroundColor: "#e2e8f0",
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 5,
|
||||
alignItems: "center",
|
||||
marginTop: 10,
|
||||
},
|
||||
secondaryButtonText: {
|
||||
color: "#4a5568",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
footer: {
|
||||
marginTop: 20,
|
||||
alignItems: "center",
|
||||
},
|
||||
footerText: {
|
||||
color: "#666",
|
||||
fontSize: 14,
|
||||
},
|
||||
link: {
|
||||
color: "#6366f1",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
});
|
||||
233
deploy/demos/ReactNativeDemo/app/signin/mfa.tsx
Normal file
233
deploy/demos/ReactNativeDemo/app/signin/mfa.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Keyboard,
|
||||
KeyboardAvoidingView,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useAuth } from "../lib/nhost/AuthProvider";
|
||||
|
||||
export default function MFAVerification() {
|
||||
const { nhost } = useAuth();
|
||||
const params = useLocalSearchParams();
|
||||
const ticket = params["ticket"] as string;
|
||||
|
||||
const [verificationCode, setVerificationCode] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Redirect if no ticket is provided
|
||||
useEffect(() => {
|
||||
if (!ticket) {
|
||||
Alert.alert("Error", "Invalid authentication request");
|
||||
router.replace("/signin");
|
||||
}
|
||||
}, [ticket]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!verificationCode || verificationCode.length !== 6) {
|
||||
setError("Please enter a valid 6-digit code");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ticket) {
|
||||
setError("Missing authentication ticket");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Complete MFA verification
|
||||
await nhost.auth.verifySignInMfaTotp({
|
||||
ticket,
|
||||
otp: verificationCode,
|
||||
});
|
||||
} catch (err) {
|
||||
const errMessage =
|
||||
err instanceof Error ? err.message : "An unexpected error occurred";
|
||||
setError(`Verification failed: ${errMessage}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior="padding"
|
||||
style={styles.container}
|
||||
keyboardVerticalOffset={40}
|
||||
>
|
||||
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollViewContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View style={styles.contentContainer}>
|
||||
<Text style={styles.title}>Multi-Factor Authentication</Text>
|
||||
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.instructions}>
|
||||
Enter the verification code from your authenticator app to
|
||||
complete sign in.
|
||||
</Text>
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Authentication Code</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={verificationCode}
|
||||
onChangeText={setVerificationCode}
|
||||
placeholder="Enter 6-digit code"
|
||||
keyboardType="number-pad"
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={() => {
|
||||
Keyboard.dismiss();
|
||||
if (verificationCode.length === 6 && !isLoading) {
|
||||
void handleSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
(isLoading || verificationCode.length !== 6) &&
|
||||
styles.buttonDisabled,
|
||||
]}
|
||||
onPress={handleSubmit}
|
||||
disabled={isLoading || verificationCode.length !== 6}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Verify</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.backLink}
|
||||
onPress={() => router.back()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Text style={styles.backLinkText}>Back to Sign In</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</TouchableWithoutFeedback>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#f5f5f5",
|
||||
},
|
||||
scrollViewContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
justifyContent: "center",
|
||||
paddingBottom: 40,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 20,
|
||||
textAlign: "center",
|
||||
color: "#333",
|
||||
},
|
||||
card: {
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 10,
|
||||
padding: 20,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
instructions: {
|
||||
fontSize: 16,
|
||||
color: "#4b5563",
|
||||
marginBottom: 20,
|
||||
textAlign: "center",
|
||||
},
|
||||
errorContainer: {
|
||||
backgroundColor: "#fee2e2",
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
marginBottom: 16,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: "#ef4444",
|
||||
},
|
||||
errorText: {
|
||||
color: "#b91c1c",
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
marginBottom: 8,
|
||||
color: "#374151",
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: "#d1d5db",
|
||||
borderRadius: 6,
|
||||
padding: 12,
|
||||
fontSize: 18,
|
||||
backgroundColor: "#f9fafb",
|
||||
textAlign: "center",
|
||||
letterSpacing: 8,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#6366f1",
|
||||
padding: 15,
|
||||
borderRadius: 6,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
buttonText: {
|
||||
color: "#fff",
|
||||
fontWeight: "bold",
|
||||
fontSize: 16,
|
||||
},
|
||||
backLink: {
|
||||
marginTop: 20,
|
||||
alignItems: "center",
|
||||
},
|
||||
backLinkText: {
|
||||
color: "#6366f1",
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
431
deploy/demos/ReactNativeDemo/app/signup.tsx
Normal file
431
deploy/demos/ReactNativeDemo/app/signup.tsx
Normal file
@@ -0,0 +1,431 @@
|
||||
import * as Linking from "expo-linking";
|
||||
import { Link, router, useLocalSearchParams } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import MagicLinkForm from "./components/MagicLinkForm";
|
||||
import NativeLoginForm from "./components/NativeLoginForm";
|
||||
import SocialLoginForm from "./components/SocialLoginForm";
|
||||
import { useAuth } from "./lib/nhost/AuthProvider";
|
||||
|
||||
export default function SignUp() {
|
||||
const { nhost, isAuthenticated } = useAuth();
|
||||
const params = useLocalSearchParams();
|
||||
|
||||
const [email, setEmail] = useState<string>("");
|
||||
const [password, setPassword] = useState<string>("");
|
||||
const [displayName, setDisplayName] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [appleAuthInProgress, setAppleAuthInProgress] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<boolean>(false);
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"password" | "magic" | "social" | "native"
|
||||
>("password");
|
||||
|
||||
const magicLinkSent = params["magic"] === "success";
|
||||
|
||||
// If already authenticated, redirect to profile
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
router.replace("/profile");
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
try {
|
||||
const response = await nhost.auth.signUpEmailPassword({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
displayName,
|
||||
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 errMessage =
|
||||
err instanceof Error ? err.message : "An unexpected error occurred";
|
||||
setError(`An error occurred during sign up: ${errMessage}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Social login is now handled by the SocialLoginForm component
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView behavior="padding" style={styles.container}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContainer}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Text style={styles.title}>Nhost SDK Demo</Text>
|
||||
|
||||
<View style={styles.card}>
|
||||
{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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.cardTitle}>Sign Up</Text>
|
||||
|
||||
{magicLinkSent ? (
|
||||
<View style={styles.messageContainer}>
|
||||
<Text style={styles.successText}>
|
||||
Magic link sent! Check your email to sign in.
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.secondaryButton}
|
||||
onPress={() => router.setParams({ magic: "" })}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>
|
||||
Back to sign up
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.tabContainer}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.tabButton,
|
||||
activeTab === "password" && styles.activeTab,
|
||||
]}
|
||||
onPress={() => setActiveTab("password")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "password" && styles.activeTabText,
|
||||
]}
|
||||
>
|
||||
Password
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.tabButton,
|
||||
activeTab === "magic" && styles.activeTab,
|
||||
]}
|
||||
onPress={() => setActiveTab("magic")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "magic" && styles.activeTabText,
|
||||
]}
|
||||
>
|
||||
Magic Link
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.tabButton,
|
||||
activeTab === "social" && styles.activeTab,
|
||||
]}
|
||||
onPress={() => setActiveTab("social")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "social" && styles.activeTabText,
|
||||
]}
|
||||
>
|
||||
Social
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.tabButton,
|
||||
activeTab === "native" && styles.activeTab,
|
||||
]}
|
||||
onPress={() => setActiveTab("native")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "native" && styles.activeTabText,
|
||||
]}
|
||||
>
|
||||
Native
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.form}>
|
||||
{activeTab === "password" ? (
|
||||
<>
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Display Name</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={displayName}
|
||||
onChangeText={setDisplayName}
|
||||
placeholder="Enter your name"
|
||||
autoCapitalize="words"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Email</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
placeholder="Enter your email"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Password</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
placeholder="Enter your password"
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<Text style={styles.helperText}>
|
||||
Password must be at least 8 characters long
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={handleSubmit}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Sign Up</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
) : activeTab === "magic" ? (
|
||||
<MagicLinkForm buttonLabel="Sign Up with Magic Link" />
|
||||
) : activeTab === "social" ? (
|
||||
<SocialLoginForm action="Sign Up" isLoading={isLoading} />
|
||||
) : (
|
||||
<NativeLoginForm
|
||||
action="Sign Up"
|
||||
isLoading={isLoading || appleAuthInProgress}
|
||||
setAppleAuthInProgress={setAppleAuthInProgress}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>
|
||||
Already have an account?{" "}
|
||||
<Link href="/signin" style={styles.link}>
|
||||
Sign In
|
||||
</Link>
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#f5f5f5",
|
||||
},
|
||||
scrollContainer: {
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
padding: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: "bold",
|
||||
textAlign: "center",
|
||||
marginBottom: 20,
|
||||
color: "#333",
|
||||
},
|
||||
card: {
|
||||
width: "100%",
|
||||
maxWidth: 400,
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 10,
|
||||
padding: 20,
|
||||
alignSelf: "center",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 20,
|
||||
textAlign: "center",
|
||||
},
|
||||
tabContainer: {
|
||||
flexDirection: "row",
|
||||
marginBottom: 20,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#e2e8f0",
|
||||
},
|
||||
tabButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
alignItems: "center",
|
||||
},
|
||||
tabText: {
|
||||
fontSize: 16,
|
||||
color: "#718096",
|
||||
},
|
||||
activeTab: {
|
||||
borderBottomWidth: 2,
|
||||
borderBottomColor: "#6366f1",
|
||||
},
|
||||
activeTabText: {
|
||||
color: "#6366f1",
|
||||
fontWeight: "600",
|
||||
},
|
||||
form: {
|
||||
width: "100%",
|
||||
},
|
||||
inputGroup: {
|
||||
marginBottom: 15,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
marginBottom: 5,
|
||||
color: "#333",
|
||||
},
|
||||
input: {
|
||||
height: 45,
|
||||
borderWidth: 1,
|
||||
borderColor: "#ddd",
|
||||
borderRadius: 5,
|
||||
paddingHorizontal: 10,
|
||||
fontSize: 16,
|
||||
backgroundColor: "#fafafa",
|
||||
},
|
||||
helperText: {
|
||||
fontSize: 12,
|
||||
color: "#666",
|
||||
marginTop: 3,
|
||||
},
|
||||
errorText: {
|
||||
color: "#e53e3e",
|
||||
marginBottom: 10,
|
||||
},
|
||||
successText: {
|
||||
color: "#38a169",
|
||||
fontSize: 16,
|
||||
textAlign: "center",
|
||||
marginBottom: 15,
|
||||
},
|
||||
successMessageBox: {
|
||||
backgroundColor: "#f0fff4",
|
||||
borderColor: "#38a169",
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
marginBottom: 20,
|
||||
},
|
||||
emailText: {
|
||||
fontWeight: "bold",
|
||||
color: "#2d3748",
|
||||
},
|
||||
messageContainer: {
|
||||
alignItems: "center",
|
||||
paddingVertical: 10,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#6366f1",
|
||||
paddingVertical: 12,
|
||||
borderRadius: 5,
|
||||
alignItems: "center",
|
||||
marginTop: 10,
|
||||
},
|
||||
buttonText: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
secondaryButton: {
|
||||
backgroundColor: "#e2e8f0",
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 5,
|
||||
alignItems: "center",
|
||||
marginTop: 10,
|
||||
},
|
||||
secondaryButtonText: {
|
||||
color: "#4a5568",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
footer: {
|
||||
marginTop: 20,
|
||||
alignItems: "center",
|
||||
},
|
||||
footerText: {
|
||||
color: "#666",
|
||||
fontSize: 14,
|
||||
},
|
||||
link: {
|
||||
color: "#6366f1",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
});
|
||||
552
deploy/demos/ReactNativeDemo/app/upload.tsx
Normal file
552
deploy/demos/ReactNativeDemo/app/upload.tsx
Normal file
@@ -0,0 +1,552 @@
|
||||
import type { FetchError } from "@nhost/nhost-js/fetch";
|
||||
import type { ErrorResponse, FileMetadata } from "@nhost/nhost-js/storage";
|
||||
import * as DocumentPicker from "expo-document-picker";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { Stack } from "expo-router";
|
||||
import * as Sharing from "expo-sharing";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import ProtectedScreen from "./components/ProtectedScreen";
|
||||
import { useAuth } from "./lib/nhost/AuthProvider";
|
||||
import { blobToBase64, formatFileSize } from "./lib/utils";
|
||||
|
||||
interface DeleteStatus {
|
||||
message: string;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
interface GraphqlGetFilesResponse {
|
||||
files: FileMetadata[];
|
||||
}
|
||||
|
||||
export default function Upload() {
|
||||
const { nhost } = useAuth();
|
||||
const [selectedFile, setSelectedFile] =
|
||||
useState<DocumentPicker.DocumentPickerResult | null>(null);
|
||||
const [uploading, setUploading] = useState<boolean>(false);
|
||||
const [uploadResult, setUploadResult] = useState<FileMetadata | null>(null);
|
||||
const [isFetching, setIsFetching] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [files, setFiles] = useState<FileMetadata[]>([]);
|
||||
const [viewingFile, setViewingFile] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const [deleteStatus, setDeleteStatus] = useState<DeleteStatus | null>(null);
|
||||
|
||||
const fetchFiles = useCallback(async () => {
|
||||
setIsFetching(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Fetch files using GraphQL query
|
||||
const response = await nhost.graphql.request<GraphqlGetFilesResponse>({
|
||||
query: `query GetFiles {
|
||||
files {
|
||||
id
|
||||
name
|
||||
size
|
||||
mimeType
|
||||
bucketId
|
||||
uploadedByUserId
|
||||
}
|
||||
}`,
|
||||
});
|
||||
|
||||
setFiles(response.body.data?.files || []);
|
||||
} catch (err) {
|
||||
const errMessage =
|
||||
err instanceof Error ? err.message : "An unexpected error occurred";
|
||||
setError(`Failed to fetch files: ${errMessage}`);
|
||||
} finally {
|
||||
setIsFetching(false);
|
||||
}
|
||||
}, [nhost.graphql]);
|
||||
|
||||
// Fetch existing files when component mounts
|
||||
useEffect(() => {
|
||||
void fetchFiles();
|
||||
}, [fetchFiles]);
|
||||
|
||||
const pickDocument = async () => {
|
||||
try {
|
||||
const result = await DocumentPicker.getDocumentAsync({
|
||||
type: "*/*", // All file types
|
||||
copyToCacheDirectory: true,
|
||||
});
|
||||
|
||||
if (!result.canceled) {
|
||||
setSelectedFile(result);
|
||||
setError(null);
|
||||
setUploadResult(null);
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Failed to pick document");
|
||||
console.error("DocumentPicker Error:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile || selectedFile.canceled) {
|
||||
setError("Please select a file to upload");
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// For React Native, we need to read the file first
|
||||
const fileToUpload = selectedFile.assets?.[0];
|
||||
if (!fileToUpload) {
|
||||
throw new Error("No file selected");
|
||||
}
|
||||
|
||||
const file: unknown = {
|
||||
uri: fileToUpload.uri,
|
||||
name: fileToUpload.name || "file",
|
||||
type: fileToUpload.mimeType || "application/octet-stream",
|
||||
};
|
||||
// Upload file using Nhost storage
|
||||
const response = await nhost.storage.uploadFiles({
|
||||
"bucket-id": "default",
|
||||
"file[]": [file as File],
|
||||
});
|
||||
|
||||
// Get the processed file data
|
||||
const uploadedFile = response.body.processedFiles?.[0];
|
||||
if (uploadedFile === undefined) {
|
||||
throw new Error("Failed to upload file");
|
||||
}
|
||||
|
||||
setUploadResult(uploadedFile);
|
||||
|
||||
// Reset form
|
||||
setSelectedFile(null);
|
||||
|
||||
// Update files list
|
||||
setFiles((prevFiles) => [uploadedFile, ...prevFiles]);
|
||||
|
||||
// Refresh file list
|
||||
await fetchFiles();
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
setUploadResult(null);
|
||||
}, 3000);
|
||||
} catch (err: unknown) {
|
||||
const error = err as FetchError<ErrorResponse>;
|
||||
setError(`Failed to upload file: ${error.message}`);
|
||||
console.error("Upload error:", err);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to handle viewing a file with proper authorization
|
||||
const handleViewFile = async (
|
||||
fileId: string,
|
||||
fileName: string,
|
||||
mimeType: string,
|
||||
) => {
|
||||
setViewingFile(fileId);
|
||||
|
||||
try {
|
||||
// Fetch the file with authentication using the SDK
|
||||
const response = await nhost.storage.getFile(fileId);
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("Failed to retrieve file contents");
|
||||
}
|
||||
|
||||
// For iOS/Android, we need to save the file to the device first
|
||||
// Create a unique temp file path with a timestamp to prevent collisions
|
||||
const fileExtension = fileName.includes(".") ? "" : ".file";
|
||||
const tempFileName = fileName.includes(".")
|
||||
? fileName
|
||||
: `${fileName}${fileExtension}`;
|
||||
const tempFilePath = `${FileSystem.cacheDirectory}${Date.now()}_${tempFileName}`;
|
||||
|
||||
// Get the blob from the response
|
||||
const blob = response.body;
|
||||
|
||||
// Convert blob to base64
|
||||
const base64Data = await blobToBase64(blob);
|
||||
|
||||
// Write the file to the filesystem
|
||||
await FileSystem.writeAsStringAsync(tempFilePath, base64Data, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
|
||||
// Check if sharing is available (iOS & Android)
|
||||
const isSharingAvailable = await Sharing.isAvailableAsync();
|
||||
|
||||
if (isSharingAvailable) {
|
||||
// Open the file with the default app
|
||||
await Sharing.shareAsync(tempFilePath, {
|
||||
mimeType: mimeType || "application/octet-stream",
|
||||
dialogTitle: `View ${fileName}`,
|
||||
UTI: mimeType, // for iOS
|
||||
});
|
||||
} else {
|
||||
throw new Error("Sharing is not available on this device");
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err as FetchError<ErrorResponse>;
|
||||
setError(`Failed to view file: ${error.message}`);
|
||||
console.error("Error viewing file:", err);
|
||||
Alert.alert("Error", `Failed to view file: ${error.message}`);
|
||||
} finally {
|
||||
setViewingFile(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to handle deleting a file
|
||||
const handleDeleteFile = (fileId: string) => {
|
||||
if (!fileId || deleting) return;
|
||||
|
||||
// Confirm deletion
|
||||
Alert.alert("Delete File", "Are you sure you want to delete this file?", [
|
||||
{
|
||||
text: "Cancel",
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
void (async () => {
|
||||
setDeleting(fileId);
|
||||
setError(null);
|
||||
setDeleteStatus(null);
|
||||
|
||||
// Get the file name for the status message
|
||||
const fileToDelete = files.find((file) => file.id === fileId);
|
||||
const fileName = fileToDelete?.name || "File";
|
||||
|
||||
try {
|
||||
// Delete the file using the Nhost storage SDK
|
||||
await nhost.storage.deleteFile(fileId);
|
||||
|
||||
// Show success message
|
||||
setDeleteStatus({
|
||||
message: `${fileName} deleted successfully`,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
// Update the local files list by removing the deleted file
|
||||
setFiles(files.filter((file) => file.id !== fileId));
|
||||
|
||||
// Refresh the file list
|
||||
await fetchFiles();
|
||||
|
||||
// Clear the success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
setDeleteStatus(null);
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
// Show error message
|
||||
const error = err as FetchError<ErrorResponse>;
|
||||
setDeleteStatus({
|
||||
message: `Failed to delete ${fileName}: ${error.message}`,
|
||||
isError: true,
|
||||
});
|
||||
console.error("Error deleting file:", err);
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
})();
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<ProtectedScreen>
|
||||
<Stack.Screen options={{ title: "File Upload" }} />
|
||||
<View style={styles.container}>
|
||||
{/* Upload Form */}
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.title}>Upload a File</Text>
|
||||
|
||||
<TouchableOpacity style={styles.fileUpload} onPress={pickDocument}>
|
||||
<View style={styles.uploadIcon}>
|
||||
<Text style={styles.uploadIconText}>⬆️</Text>
|
||||
</View>
|
||||
<Text style={styles.uploadText}>Tap to select a file</Text>
|
||||
{selectedFile &&
|
||||
!selectedFile.canceled &&
|
||||
selectedFile.assets?.[0] && (
|
||||
<Text style={styles.fileName}>
|
||||
{selectedFile.assets[0].name}(
|
||||
{formatFileSize(selectedFile.assets[0].size || 0)})
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{uploadResult && (
|
||||
<View style={styles.successContainer}>
|
||||
<Text style={styles.successText}>
|
||||
File uploaded successfully!
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
(!selectedFile || selectedFile.canceled || uploading) &&
|
||||
styles.buttonDisabled,
|
||||
]}
|
||||
onPress={handleUpload}
|
||||
disabled={!selectedFile || selectedFile.canceled || uploading}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{uploading ? "Uploading..." : "Upload File"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Files List */}
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.title}>Your Files</Text>
|
||||
|
||||
{deleteStatus && (
|
||||
<View
|
||||
style={[
|
||||
styles.statusContainer,
|
||||
deleteStatus.isError
|
||||
? styles.errorContainer
|
||||
: styles.successContainer,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={
|
||||
deleteStatus.isError ? styles.errorText : styles.successText
|
||||
}
|
||||
>
|
||||
{deleteStatus.message}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isFetching ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#0000ff" />
|
||||
<Text style={styles.loadingText}>Loading files...</Text>
|
||||
</View>
|
||||
) : files.length === 0 ? (
|
||||
<Text style={styles.emptyText}>No files uploaded yet.</Text>
|
||||
) : (
|
||||
<FlatList
|
||||
data={files}
|
||||
keyExtractor={(item) => item.id || Math.random().toString()}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.fileItem}>
|
||||
<View style={styles.fileInfo}>
|
||||
<Text style={styles.fileNameText} numberOfLines={1}>
|
||||
{item.name}
|
||||
</Text>
|
||||
<Text style={styles.fileDetails}>
|
||||
{item.mimeType} • {formatFileSize(item.size || 0)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.fileActions}>
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={() =>
|
||||
handleViewFile(
|
||||
item.id || "unknown",
|
||||
item.name || "unknown",
|
||||
item.mimeType || "unknown",
|
||||
)
|
||||
}
|
||||
disabled={viewingFile === item.id}
|
||||
>
|
||||
{viewingFile === item.id ? (
|
||||
<Text style={styles.actionText}>⌛</Text>
|
||||
) : (
|
||||
<Text style={styles.actionText}>👁️</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.deleteButton]}
|
||||
onPress={() => handleDeleteFile(item.id || "unknown")}
|
||||
disabled={deleting === item.id}
|
||||
>
|
||||
{deleting === item.id ? (
|
||||
<Text style={styles.actionText}>⌛</Text>
|
||||
) : (
|
||||
<Text style={styles.actionText}>🗑️</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
style={styles.fileList}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</ProtectedScreen>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
backgroundColor: "#f5f5f5",
|
||||
},
|
||||
card: {
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 10,
|
||||
padding: 20,
|
||||
marginBottom: 20,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 16,
|
||||
color: "#333",
|
||||
},
|
||||
fileUpload: {
|
||||
borderWidth: 2,
|
||||
borderColor: "#ddd",
|
||||
borderStyle: "dashed",
|
||||
borderRadius: 10,
|
||||
padding: 20,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#f9f9f9",
|
||||
marginBottom: 16,
|
||||
},
|
||||
uploadIcon: {
|
||||
marginBottom: 10,
|
||||
},
|
||||
uploadIconText: {
|
||||
fontSize: 24,
|
||||
},
|
||||
uploadText: {
|
||||
fontSize: 16,
|
||||
color: "#666",
|
||||
},
|
||||
fileName: {
|
||||
marginTop: 8,
|
||||
color: "#0066cc",
|
||||
fontSize: 14,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#0066cc",
|
||||
padding: 15,
|
||||
borderRadius: 8,
|
||||
alignItems: "center",
|
||||
},
|
||||
buttonDisabled: {
|
||||
backgroundColor: "#ccc",
|
||||
},
|
||||
buttonText: {
|
||||
color: "white",
|
||||
fontWeight: "bold",
|
||||
fontSize: 16,
|
||||
},
|
||||
errorContainer: {
|
||||
backgroundColor: "#ffebee",
|
||||
padding: 10,
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: "#f44336",
|
||||
},
|
||||
errorText: {
|
||||
color: "#d32f2f",
|
||||
},
|
||||
successContainer: {
|
||||
backgroundColor: "#e8f5e9",
|
||||
padding: 10,
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: "#4caf50",
|
||||
},
|
||||
successText: {
|
||||
color: "#2e7d32",
|
||||
},
|
||||
statusContainer: {
|
||||
padding: 10,
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
borderLeftWidth: 4,
|
||||
},
|
||||
loadingContainer: {
|
||||
alignItems: "center",
|
||||
padding: 20,
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 10,
|
||||
color: "#666",
|
||||
},
|
||||
emptyText: {
|
||||
textAlign: "center",
|
||||
color: "#666",
|
||||
padding: 20,
|
||||
},
|
||||
fileList: {
|
||||
maxHeight: 300,
|
||||
},
|
||||
fileItem: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#eee",
|
||||
},
|
||||
fileInfo: {
|
||||
flex: 1,
|
||||
paddingRight: 10,
|
||||
},
|
||||
fileNameText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "500",
|
||||
color: "#333",
|
||||
marginBottom: 4,
|
||||
},
|
||||
fileDetails: {
|
||||
fontSize: 12,
|
||||
color: "#777",
|
||||
},
|
||||
fileActions: {
|
||||
flexDirection: "row",
|
||||
},
|
||||
actionButton: {
|
||||
padding: 8,
|
||||
marginHorizontal: 4,
|
||||
borderRadius: 20,
|
||||
backgroundColor: "#f0f0f0",
|
||||
},
|
||||
deleteButton: {
|
||||
backgroundColor: "#fff0f0",
|
||||
},
|
||||
actionText: {
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
265
deploy/demos/ReactNativeDemo/app/verify.tsx
Normal file
265
deploy/demos/ReactNativeDemo/app/verify.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useAuth } from "./lib/nhost/AuthProvider";
|
||||
|
||||
export default function Verify() {
|
||||
const params = useLocalSearchParams<{ refreshToken: string }>();
|
||||
const [status, setStatus] = useState<"verifying" | "success" | "error">(
|
||||
"verifying",
|
||||
);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const { nhost, isAuthenticated } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const refreshToken = params.refreshToken;
|
||||
|
||||
if (!refreshToken) {
|
||||
setStatus("error");
|
||||
setError("No refresh token found in the link");
|
||||
return;
|
||||
}
|
||||
|
||||
// Flag to handle component unmounting during async operations
|
||||
let isMounted = true;
|
||||
|
||||
async function processToken(): Promise<void> {
|
||||
try {
|
||||
// First display the verifying message for at least a moment
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
if (!refreshToken) {
|
||||
// Collect all URL parameters to display
|
||||
const allParams: Record<string, string> = {};
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (typeof value === "string") {
|
||||
allParams[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
setStatus("error");
|
||||
setError("No refresh token found in the link");
|
||||
return;
|
||||
}
|
||||
|
||||
// Process the token
|
||||
await nhost.auth.refreshToken({ refreshToken });
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
setStatus("success");
|
||||
|
||||
// Wait to show success message briefly, then redirect
|
||||
setTimeout(() => {
|
||||
if (isMounted) router.replace("/profile");
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
if (!isMounted) return;
|
||||
|
||||
const errMessage =
|
||||
err instanceof Error ? err.message : "An unexpected error occurred";
|
||||
|
||||
setStatus("error");
|
||||
setError(`An error occurred during verification: ${errMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
void processToken();
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [params, nhost.auth]);
|
||||
|
||||
// If already authenticated and not handling verification, redirect to profile
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && status !== "verifying") {
|
||||
router.replace("/profile");
|
||||
}
|
||||
}, [isAuthenticated, status]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Nhost SDK Demo</Text>
|
||||
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>Email Verification</Text>
|
||||
|
||||
<View style={styles.contentContainer}>
|
||||
{status === "verifying" && (
|
||||
<View>
|
||||
<Text style={styles.statusText}>Verifying your email...</Text>
|
||||
<ActivityIndicator
|
||||
size="large"
|
||||
color="#6366f1"
|
||||
style={styles.spinner}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{status === "success" && (
|
||||
<View>
|
||||
<Text style={styles.successText}>✓ Successfully verified!</Text>
|
||||
<Text style={styles.statusText}>
|
||||
You'll be redirected to your profile page shortly...
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<View>
|
||||
<Text style={styles.errorText}>Verification failed</Text>
|
||||
<Text style={styles.statusText}>{error}</Text>
|
||||
|
||||
<View style={styles.debugInfo}>
|
||||
<Text style={styles.debugTitle}>Testing in Expo Go?</Text>
|
||||
<Text style={styles.debugText}>
|
||||
Make sure your magic link uses the proper Expo Go format.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => router.replace("/signin")}
|
||||
style={styles.button}
|
||||
>
|
||||
<Text style={styles.buttonText}>Back to Sign In</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
debugInfo: {
|
||||
backgroundColor: "#fff8dc",
|
||||
padding: 10,
|
||||
borderRadius: 5,
|
||||
marginVertical: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: "#ffd700",
|
||||
},
|
||||
debugTitle: {
|
||||
fontWeight: "bold",
|
||||
marginBottom: 5,
|
||||
color: "#b8860b",
|
||||
},
|
||||
debugText: {
|
||||
color: "#5a4a00",
|
||||
fontSize: 14,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#f5f5f5",
|
||||
justifyContent: "center",
|
||||
padding: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: "bold",
|
||||
textAlign: "center",
|
||||
marginBottom: 20,
|
||||
color: "#333",
|
||||
},
|
||||
card: {
|
||||
width: "100%",
|
||||
maxWidth: 400,
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 10,
|
||||
padding: 20,
|
||||
alignSelf: "center",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 20,
|
||||
textAlign: "center",
|
||||
},
|
||||
contentContainer: {
|
||||
alignItems: "center",
|
||||
paddingVertical: 20,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 16,
|
||||
textAlign: "center",
|
||||
marginBottom: 15,
|
||||
color: "#4a5568",
|
||||
},
|
||||
spinner: {
|
||||
marginVertical: 20,
|
||||
},
|
||||
successText: {
|
||||
color: "#38a169",
|
||||
fontSize: 18,
|
||||
fontWeight: "bold",
|
||||
textAlign: "center",
|
||||
marginBottom: 10,
|
||||
},
|
||||
errorText: {
|
||||
color: "#e53e3e",
|
||||
fontSize: 18,
|
||||
fontWeight: "bold",
|
||||
textAlign: "center",
|
||||
marginBottom: 10,
|
||||
},
|
||||
paramsContainer: {
|
||||
backgroundColor: "#f7fafc",
|
||||
borderRadius: 5,
|
||||
padding: 10,
|
||||
marginVertical: 15,
|
||||
width: "100%",
|
||||
maxHeight: 150,
|
||||
},
|
||||
paramsTitle: {
|
||||
fontWeight: "bold",
|
||||
marginBottom: 5,
|
||||
color: "#2d3748",
|
||||
},
|
||||
paramRow: {
|
||||
flexDirection: "row",
|
||||
marginBottom: 5,
|
||||
},
|
||||
paramKey: {
|
||||
color: "#4299e1",
|
||||
marginRight: 5,
|
||||
fontFamily: "monospace",
|
||||
},
|
||||
paramValue: {
|
||||
flex: 1,
|
||||
fontFamily: "monospace",
|
||||
color: "#2d3748",
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#6366f1",
|
||||
paddingVertical: 12,
|
||||
borderRadius: 5,
|
||||
alignItems: "center",
|
||||
marginTop: 15,
|
||||
width: "100%",
|
||||
},
|
||||
buttonText: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
BIN
deploy/demos/ReactNativeDemo/assets/images/adaptive-icon.png
Normal file
BIN
deploy/demos/ReactNativeDemo/assets/images/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
deploy/demos/ReactNativeDemo/assets/images/splash-icon.png
Normal file
BIN
deploy/demos/ReactNativeDemo/assets/images/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
64
deploy/demos/ReactNativeDemo/package.json
Normal file
64
deploy/demos/ReactNativeDemo/package.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "reactnativewebdemo",
|
||||
"main": "expo-router/entry",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"generate": "echo 'Nothing to do'",
|
||||
"test": "pnpm test:typecheck && pnpm test:lint",
|
||||
"test:typecheck": "tsc --noEmit",
|
||||
"test:lint": "biome check",
|
||||
"format": "biome format --write",
|
||||
"build": "pnpm expo export -p ios -p android",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^14.1.0",
|
||||
"@nhost/nhost-js": "workspace:*",
|
||||
"@react-native-async-storage/async-storage": "^2.1.2",
|
||||
"@react-navigation/bottom-tabs": "^7.3.14",
|
||||
"@react-navigation/elements": "^2.4.3",
|
||||
"@react-navigation/native": "^7.1.10",
|
||||
"expo": "~53.0.10",
|
||||
"expo-apple-authentication": "~7.2.4",
|
||||
"expo-blur": "~14.1.5",
|
||||
"expo-clipboard": "^7.1.4",
|
||||
"expo-constants": "~17.1.6",
|
||||
"expo-crypto": "~14.1.4",
|
||||
"expo-document-picker": "^13.1.5",
|
||||
"expo-file-system": "^18.1.10",
|
||||
"expo-font": "~13.3.1",
|
||||
"expo-haptics": "~14.1.4",
|
||||
"expo-image": "~2.2.0",
|
||||
"expo-linking": "~7.1.5",
|
||||
"expo-router": "~5.0.7",
|
||||
"expo-sharing": "^13.1.5",
|
||||
"expo-splash-screen": "~0.30.9",
|
||||
"expo-status-bar": "~2.2.3",
|
||||
"expo-symbols": "~0.4.5",
|
||||
"expo-system-ui": "~5.0.8",
|
||||
"expo-web-browser": "~14.1.6",
|
||||
"react": "19.0.0",
|
||||
"react-native": "0.79.3",
|
||||
"react-native-gesture-handler": "~2.24.0",
|
||||
"react-native-reanimated": "~3.17.5",
|
||||
"react-native-safe-area-context": "5.4.0",
|
||||
"react-native-screens": "^4.11.1",
|
||||
"react-native-webview": "13.13.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.27.4",
|
||||
"@types/react": "~19.0.14",
|
||||
"@types/node": "^22.15.17"
|
||||
},
|
||||
"private": true,
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"js-yaml@<=4.1.0": ">=4.1.1",
|
||||
"glob@>=10.3.7 <=11.0.3": ">=11.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
6275
deploy/demos/ReactNativeDemo/pnpm-lock.yaml
generated
Normal file
6275
deploy/demos/ReactNativeDemo/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
deploy/demos/ReactNativeDemo/tsconfig.json
Normal file
15
deploy/demos/ReactNativeDemo/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": [
|
||||
"expo/tsconfig.base",
|
||||
"../../../build/configs/tsconfig/base.json"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
},
|
||||
/* Override specific settings from base.json as needed for React Native */
|
||||
"lib": ["ESNext"],
|
||||
"jsx": "react-native"
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
|
||||
}
|
||||
2
deploy/demos/backend/.gitignore
vendored
Normal file
2
deploy/demos/backend/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.nhost
|
||||
.secrets
|
||||
16
deploy/demos/backend/.secrets.example
Normal file
16
deploy/demos/backend/.secrets.example
Normal file
@@ -0,0 +1,16 @@
|
||||
GRAFANA_ADMIN_PASSWORD = 'grafana-admin-password'
|
||||
HASURA_GRAPHQL_ADMIN_SECRET = 'nhost-admin-secret'
|
||||
HASURA_GRAPHQL_JWT_SECRET = '55b1d038dff8d4f9a440e848250668527fa5b563700be0dc39e356f1c91f867e'
|
||||
NHOST_WEBHOOK_SECRET = 'nhost-webhook-secret'
|
||||
GITHUB_CLIENT_ID='fixme'
|
||||
GITHUB_CLIENT_SECRET='fixme'
|
||||
APPLE_TEAM_ID='fakeTeamId'
|
||||
APPLE_CLIENT_ID='host.exp.Exponent'
|
||||
APPLE_AUDIENCE='host.exp.Exponent'
|
||||
APPLE_KEY_ID='fakeKeyId'
|
||||
APPLE_PRIVATE_KEY='''-----BEGIN PRIVATE KEY-----
|
||||
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQglHTWHjauHnKCxjEP
|
||||
BpMYsTDI2cihQi4tAYHTthj+FF+gCgYIKoZIzj0DAQehRANCAAR30Hs8vTbED10z
|
||||
Qx2m4sJu+lE/ZJsRvDkqLqYF8uh1Tb1g7/KKr8Y7qkK3DmCg72bCyirEq4NVUi2r
|
||||
M/6TYMpw
|
||||
-----END PRIVATE KEY-----'''
|
||||
7
deploy/demos/backend/Makefile
Normal file
7
deploy/demos/backend/Makefile
Normal file
@@ -0,0 +1,7 @@
|
||||
.PHONY: dev-env-up
|
||||
dev-env-up:
|
||||
@./env-up.sh
|
||||
|
||||
.PHONY: dev-env-down
|
||||
dev-env-down:
|
||||
@nhost down --volumes
|
||||
29
deploy/demos/backend/README.md
Normal file
29
deploy/demos/backend/README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# backend
|
||||
|
||||
This is a very simple Nhost backend that we will use to demonstrate how to use the various SDKs we are experimenting with. The backend will consist of the following:
|
||||
|
||||
## Database schema
|
||||
|
||||
- A `tasks` table with the following columns:
|
||||
|
||||
- `id` (UUID)
|
||||
- `created_at` (Timestamp)
|
||||
- `updated_at` (Timestamp)
|
||||
- `user_id` (foreigh key to `auth.users.id`)
|
||||
- `title` (Text)
|
||||
- `description` (Text)
|
||||
- `completed` (Boolean)
|
||||
|
||||
- An `attachments` table with the following columns:
|
||||
- `task_id` (foreign key to `tasks.id`)
|
||||
- `file_id` (foreign key to `storage.files.id`)
|
||||
|
||||
Permissions:
|
||||
|
||||
- `tasks`: the `user` role can insert/select/update tasks that they own. Ownership is tracked by the `user_id` column which is set automatically on insert from the session.
|
||||
- `attachments`: the `user` role can insert/select/delete attachments for tasks and files that they own
|
||||
- `storage.files`: the `user` role can insert/select/delete files that they own
|
||||
|
||||
## Functions
|
||||
|
||||
- A `simple` function called `echo` that will just return back some request information
|
||||
8
deploy/demos/backend/env-up.sh
Executable file
8
deploy/demos/backend/env-up.sh
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
|
||||
# if .secrets file doesn't exist, cp .secrets.example .secrets
|
||||
if [ ! -f .secrets ]; then
|
||||
cp .secrets.example .secrets
|
||||
fi
|
||||
|
||||
nhost up
|
||||
14
deploy/demos/backend/functions/package-lock.json
generated
Normal file
14
deploy/demos/backend/functions/package-lock.json
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "functions",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "functions",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
13
deploy/demos/backend/functions/package.json
Normal file
13
deploy/demos/backend/functions/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "functions",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"devDependencies": {},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC"
|
||||
}
|
||||
11
deploy/demos/backend/functions/tsconfig.json
Normal file
11
deploy/demos/backend/functions/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": false
|
||||
}
|
||||
}
|
||||
1
deploy/demos/backend/nhost/config.yaml
Normal file
1
deploy/demos/backend/nhost/config.yaml
Normal file
@@ -0,0 +1 @@
|
||||
version: 3
|
||||
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Потвърдете смяната на вашия имейл</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Използвайте посочения линк, за да повърдите смяната на имейл:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>   </i><![endif]--></span
|
||||
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Смени имейл</span
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>   ​</i><![endif]--></span
|
||||
></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Потвърждение за смяна на имейл
|
||||
52
deploy/demos/backend/nhost/emails/bg/email-verify/body.html
Normal file
52
deploy/demos/backend/nhost/emails/bg/email-verify/body.html
Normal file
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Потвърдете вашия имейл</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Използвайте посочения линк, за да потвърдите вашия имейл:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>   </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
deploy/demos/backend/nhost/emails/bg/signin-otp/body.html
Normal file
43
deploy/demos/backend/nhost/emails/bg/signin-otp/body.html
Normal file
@@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">One-time Password</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">За да влезете в ${redirectTo}, моля, използвайте следната еднократна парола:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><p style="font-size: 24px; line-height: 32px; margin: 16px 0; color: #0052cd; font-weight: 600">${ticket}</p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Еднократна парола за ${redirectTo}
|
||||
@@ -0,0 +1 @@
|
||||
Вашият код е ${code}.
|
||||
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Магически линк за вход</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Използвайте посочения линк за защитен и бърз вход:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>   </i><![endif]--></span
|
||||
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Вход</span
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>   ​</i><![endif]--></span
|
||||
></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Магически линк за вход
|
||||
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Potvrzení změny emailové adresy</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Použijte tento odkaz k potvrzení změny emailové adresy:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>   </i><![endif]--></span
|
||||
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Změnit email</span
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>   ​</i><![endif]--></span
|
||||
></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Změna vaší emailové adresy
|
||||
52
deploy/demos/backend/nhost/emails/cs/email-verify/body.html
Normal file
52
deploy/demos/backend/nhost/emails/cs/email-verify/body.html
Normal file
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Ověření emailové adresy</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Použijte tento odkaz k ověření vaší emailové adresy:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>   </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
deploy/demos/backend/nhost/emails/cs/signin-otp/body.html
Normal file
43
deploy/demos/backend/nhost/emails/cs/signin-otp/body.html
Normal file
@@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">One-time Password</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Pro přihlášení do ${redirectTo}, prosím, použijte následující jednorázové heslo:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><p style="font-size: 24px; line-height: 32px; margin: 16px 0; color: #0052cd; font-weight: 600">${ticket}</p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Jednorázové heslo pro ${redirectTo}
|
||||
@@ -0,0 +1 @@
|
||||
Váš kód je ${code}.
|
||||
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Magický odkaz</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Použijte tento odkaz k bezpečnému přihlášení:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>   </i><![endif]--></span
|
||||
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Přihlášení</span
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>   ​</i><![endif]--></span
|
||||
></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Bezpečný odkaz k přihlášení
|
||||
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Confirm Email Change</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Use this link to confirm changing email:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>   </i><![endif]--></span
|
||||
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Change Email</span
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>   ​</i><![endif]--></span
|
||||
></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Change your email address
|
||||
52
deploy/demos/backend/nhost/emails/en/email-verify/body.html
Normal file
52
deploy/demos/backend/nhost/emails/en/email-verify/body.html
Normal file
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Verify Email</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Use this link to verify your email:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>   </i><![endif]--></span
|
||||
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Verify Email</span
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>   ​</i><![endif]--></span
|
||||
></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Verify your email
|
||||
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Reset Password</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Use this link to reset your password:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>   </i><![endif]--></span
|
||||
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Reset Password</span
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>   ​</i><![endif]--></span
|
||||
></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Reset your password
|
||||
43
deploy/demos/backend/nhost/emails/en/signin-otp/body.html
Normal file
43
deploy/demos/backend/nhost/emails/en/signin-otp/body.html
Normal file
@@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">One-time Password</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">To sign in to ${redirectTo}, please, use the following one-time password:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><p style="font-size: 24px; line-height: 32px; margin: 16px 0; color: #0052cd; font-weight: 600">${ticket}</p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
One-time password for ${redirectTo}
|
||||
@@ -0,0 +1 @@
|
||||
Your code is ${code}.
|
||||
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Magic Link</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Use this link to securely sign in:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>   </i><![endif]--></span
|
||||
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Magic Link</span
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>   ​</i><![endif]--></span
|
||||
></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Secure sign-in link
|
||||
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Confirmar cambio de correo electrónico</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Utiliza el siguiente enlace para confirmar el cambio de correo:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>   </i><![endif]--></span
|
||||
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Cambiar correo electrónico</span
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>   ​</i><![endif]--></span
|
||||
></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Cambiar dirección de correo electrónico
|
||||
52
deploy/demos/backend/nhost/emails/es/email-verify/body.html
Normal file
52
deploy/demos/backend/nhost/emails/es/email-verify/body.html
Normal file
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Verificar correo electrónico</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Utilza el siguiente enlace para verificar tu correo:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>   </i><![endif]--></span
|
||||
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Verificar correo electrónico</span
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>   ​</i><![endif]--></span
|
||||
></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Verifica tu correo electrónico
|
||||
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Recuperar contraseña</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Utiliza el siguiente enlace para recuperar tu contraseña:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>   </i><![endif]--></span
|
||||
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Recuperar contraseña</span
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>   ​</i><![endif]--></span
|
||||
></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Recuperar contraseña
|
||||
43
deploy/demos/backend/nhost/emails/es/signin-otp/body.html
Normal file
43
deploy/demos/backend/nhost/emails/es/signin-otp/body.html
Normal file
@@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">One-time Password</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Para iniciar sesión en ${redirectTo}, por favor, utilice la siguiente contraseña de un solo uso:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><p style="font-size: 24px; line-height: 32px; margin: 16px 0; color: #0052cd; font-weight: 600">${ticket}</p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Contraseña de un solo uso para ${redirectTo}
|
||||
@@ -0,0 +1 @@
|
||||
Tu código es ${code}.
|
||||
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
|
||||
<tbody>
|
||||
<tr style="width: 100%">
|
||||
<td>
|
||||
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Enlace mágico</h1>
|
||||
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Utiliza este enlace para iniciar sesión de forma segura:</p>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>   </i><![endif]--></span
|
||||
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Iniciar sesión</span
|
||||
><span
|
||||
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>   ​</i><![endif]--></span
|
||||
></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tbody style="width: 100%">
|
||||
<tr style="width: 100%">
|
||||
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
|
||||
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user