appwrite-utils-cli 1.0.8 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +102 -0
- package/dist/collections/attributes.d.ts +8 -0
- package/dist/collections/attributes.js +195 -0
- package/dist/collections/indexes.d.ts +8 -0
- package/dist/collections/indexes.js +150 -0
- package/dist/collections/methods.js +105 -53
- package/dist/interactiveCLI.js +143 -48
- package/dist/migrations/transfer.js +111 -53
- package/package.json +1 -1
- package/src/collections/attributes.ts +339 -0
- package/src/collections/indexes.ts +264 -0
- package/src/collections/methods.ts +175 -87
- package/src/interactiveCLI.ts +146 -48
- package/src/migrations/transfer.ts +228 -121
@@ -23,6 +23,7 @@ import {
|
|
23
23
|
import { delay, tryAwaitWithRetry } from "../utils/helperFunctions.js";
|
24
24
|
import { MessageFormatter } from "../shared/messageFormatter.js";
|
25
25
|
import { ProgressManager } from "../shared/progressManager.js";
|
26
|
+
import chalk from "chalk";
|
26
27
|
|
27
28
|
export const documentExists = async (
|
28
29
|
db: Databases,
|
@@ -615,6 +616,123 @@ export const transferDocumentsBetweenDbsLocalToLocal = async (
|
|
615
616
|
);
|
616
617
|
};
|
617
618
|
|
619
|
+
/**
|
620
|
+
* Enhanced document transfer with fault tolerance and exponential backoff
|
621
|
+
*/
|
622
|
+
const transferDocumentWithRetry = async (
|
623
|
+
db: Databases,
|
624
|
+
dbId: string,
|
625
|
+
collectionId: string,
|
626
|
+
documentId: string,
|
627
|
+
documentData: any,
|
628
|
+
permissions: string[],
|
629
|
+
maxRetries: number = 3,
|
630
|
+
retryCount: number = 0
|
631
|
+
): Promise<boolean> => {
|
632
|
+
try {
|
633
|
+
await db.createDocument(
|
634
|
+
dbId,
|
635
|
+
collectionId,
|
636
|
+
documentId,
|
637
|
+
documentData,
|
638
|
+
permissions
|
639
|
+
);
|
640
|
+
return true;
|
641
|
+
} catch (error: any) {
|
642
|
+
// Check if document already exists
|
643
|
+
if (error.code === 409 || error.message?.includes('already exists')) {
|
644
|
+
console.log(chalk.yellow(`Document ${documentId} already exists, skipping...`));
|
645
|
+
return true;
|
646
|
+
}
|
647
|
+
|
648
|
+
if (retryCount < maxRetries) {
|
649
|
+
// Calculate exponential backoff: 1s, 2s, 4s
|
650
|
+
const exponentialDelay = Math.min(1000 * Math.pow(2, retryCount), 8000);
|
651
|
+
console.log(chalk.yellow(`Retrying document ${documentId} (attempt ${retryCount + 1}/${maxRetries}, backoff: ${exponentialDelay}ms)`));
|
652
|
+
|
653
|
+
await delay(exponentialDelay);
|
654
|
+
|
655
|
+
return await transferDocumentWithRetry(
|
656
|
+
db,
|
657
|
+
dbId,
|
658
|
+
collectionId,
|
659
|
+
documentId,
|
660
|
+
documentData,
|
661
|
+
permissions,
|
662
|
+
maxRetries,
|
663
|
+
retryCount + 1
|
664
|
+
);
|
665
|
+
}
|
666
|
+
|
667
|
+
console.log(chalk.red(`Failed to transfer document ${documentId} after ${maxRetries} retries: ${error.message}`));
|
668
|
+
return false;
|
669
|
+
}
|
670
|
+
};
|
671
|
+
|
672
|
+
/**
|
673
|
+
* Enhanced batch document transfer with fault tolerance
|
674
|
+
*/
|
675
|
+
const transferDocumentBatchWithRetry = async (
|
676
|
+
db: Databases,
|
677
|
+
dbId: string,
|
678
|
+
collectionId: string,
|
679
|
+
documents: any[],
|
680
|
+
batchSize: number = 10
|
681
|
+
): Promise<{ successful: number; failed: number }> => {
|
682
|
+
let successful = 0;
|
683
|
+
let failed = 0;
|
684
|
+
|
685
|
+
// Process documents in smaller batches to avoid overwhelming the server
|
686
|
+
const documentBatches = chunk(documents, batchSize);
|
687
|
+
|
688
|
+
for (const batch of documentBatches) {
|
689
|
+
console.log(chalk.blue(`Processing batch of ${batch.length} documents...`));
|
690
|
+
|
691
|
+
const batchPromises = batch.map(async (doc) => {
|
692
|
+
const toCreateObject: Partial<typeof doc> = { ...doc };
|
693
|
+
delete toCreateObject.$databaseId;
|
694
|
+
delete toCreateObject.$collectionId;
|
695
|
+
delete toCreateObject.$createdAt;
|
696
|
+
delete toCreateObject.$updatedAt;
|
697
|
+
delete toCreateObject.$id;
|
698
|
+
delete toCreateObject.$permissions;
|
699
|
+
|
700
|
+
const result = await transferDocumentWithRetry(
|
701
|
+
db,
|
702
|
+
dbId,
|
703
|
+
collectionId,
|
704
|
+
doc.$id,
|
705
|
+
toCreateObject,
|
706
|
+
doc.$permissions || []
|
707
|
+
);
|
708
|
+
|
709
|
+
return { docId: doc.$id, success: result };
|
710
|
+
});
|
711
|
+
|
712
|
+
const results = await Promise.allSettled(batchPromises);
|
713
|
+
|
714
|
+
results.forEach((result, index) => {
|
715
|
+
if (result.status === 'fulfilled') {
|
716
|
+
if (result.value.success) {
|
717
|
+
successful++;
|
718
|
+
} else {
|
719
|
+
failed++;
|
720
|
+
}
|
721
|
+
} else {
|
722
|
+
console.log(chalk.red(`Batch promise rejected for document ${batch[index].$id}: ${result.reason}`));
|
723
|
+
failed++;
|
724
|
+
}
|
725
|
+
});
|
726
|
+
|
727
|
+
// Add delay between batches to avoid rate limiting
|
728
|
+
if (documentBatches.indexOf(batch) < documentBatches.length - 1) {
|
729
|
+
await delay(500);
|
730
|
+
}
|
731
|
+
}
|
732
|
+
|
733
|
+
return { successful, failed };
|
734
|
+
};
|
735
|
+
|
618
736
|
export const transferDocumentsBetweenDbsLocalToRemote = async (
|
619
737
|
localDb: Databases,
|
620
738
|
endpoint: string,
|
@@ -625,100 +743,70 @@ export const transferDocumentsBetweenDbsLocalToRemote = async (
|
|
625
743
|
fromCollId: string,
|
626
744
|
toCollId: string
|
627
745
|
) => {
|
746
|
+
console.log(chalk.blue(`Starting enhanced document transfer from ${fromCollId} to ${toCollId}...`));
|
747
|
+
|
628
748
|
const client = new Client()
|
629
749
|
.setEndpoint(endpoint)
|
630
750
|
.setProject(projectId)
|
631
751
|
.setKey(apiKey);
|
632
|
-
|
752
|
+
|
633
753
|
const remoteDb = new Databases(client);
|
634
|
-
let
|
635
|
-
|
636
|
-
|
637
|
-
|
638
|
-
|
754
|
+
let totalDocumentsProcessed = 0;
|
755
|
+
let totalSuccessful = 0;
|
756
|
+
let totalFailed = 0;
|
757
|
+
|
758
|
+
// Fetch documents in batches
|
759
|
+
let hasMoreDocuments = true;
|
760
|
+
let lastDocumentId: string | undefined;
|
761
|
+
|
762
|
+
while (hasMoreDocuments) {
|
763
|
+
const queries = [Query.limit(50)];
|
764
|
+
if (lastDocumentId) {
|
765
|
+
queries.push(Query.cursorAfter(lastDocumentId));
|
766
|
+
}
|
767
|
+
|
768
|
+
const fromCollDocs = await tryAwaitWithRetry(async () =>
|
769
|
+
localDb.listDocuments(fromDbId, fromCollId, queries)
|
770
|
+
);
|
771
|
+
|
772
|
+
if (fromCollDocs.documents.length === 0) {
|
773
|
+
hasMoreDocuments = false;
|
774
|
+
break;
|
775
|
+
}
|
776
|
+
|
777
|
+
console.log(chalk.blue(`Processing ${fromCollDocs.documents.length} documents...`));
|
778
|
+
|
779
|
+
const { successful, failed } = await transferDocumentBatchWithRetry(
|
780
|
+
remoteDb,
|
781
|
+
toDbId,
|
782
|
+
toCollId,
|
783
|
+
fromCollDocs.documents
|
784
|
+
);
|
785
|
+
|
786
|
+
totalDocumentsProcessed += fromCollDocs.documents.length;
|
787
|
+
totalSuccessful += successful;
|
788
|
+
totalFailed += failed;
|
789
|
+
|
790
|
+
// Check if we have more documents to process
|
791
|
+
if (fromCollDocs.documents.length < 50) {
|
792
|
+
hasMoreDocuments = false;
|
793
|
+
} else {
|
794
|
+
lastDocumentId = fromCollDocs.documents[fromCollDocs.documents.length - 1].$id;
|
795
|
+
}
|
796
|
+
|
797
|
+
console.log(chalk.gray(`Batch complete: ${successful} successful, ${failed} failed`));
|
798
|
+
}
|
799
|
+
|
800
|
+
if (totalDocumentsProcessed === 0) {
|
639
801
|
MessageFormatter.info(`No documents found in collection ${fromCollId}`, { prefix: "Transfer" });
|
640
802
|
return;
|
641
|
-
}
|
642
|
-
|
643
|
-
|
644
|
-
|
645
|
-
|
646
|
-
|
647
|
-
delete toCreateObject.$collectionId;
|
648
|
-
delete toCreateObject.$createdAt;
|
649
|
-
delete toCreateObject.$updatedAt;
|
650
|
-
delete toCreateObject.$id;
|
651
|
-
delete toCreateObject.$permissions;
|
652
|
-
return tryAwaitWithRetry(async () =>
|
653
|
-
remoteDb.createDocument(
|
654
|
-
toDbId,
|
655
|
-
toCollId,
|
656
|
-
doc.$id,
|
657
|
-
toCreateObject,
|
658
|
-
doc.$permissions
|
659
|
-
)
|
660
|
-
);
|
661
|
-
});
|
662
|
-
await Promise.all(batchedPromises);
|
663
|
-
totalDocumentsTransferred += fromCollDocs.documents.length;
|
803
|
+
}
|
804
|
+
|
805
|
+
const message = `Total documents processed: ${totalDocumentsProcessed}, successful: ${totalSuccessful}, failed: ${totalFailed}`;
|
806
|
+
|
807
|
+
if (totalFailed > 0) {
|
808
|
+
MessageFormatter.warning(message, { prefix: "Transfer" });
|
664
809
|
} else {
|
665
|
-
|
666
|
-
const toCreateObject: Partial<typeof doc> = {
|
667
|
-
...doc,
|
668
|
-
};
|
669
|
-
delete toCreateObject.$databaseId;
|
670
|
-
delete toCreateObject.$collectionId;
|
671
|
-
delete toCreateObject.$createdAt;
|
672
|
-
delete toCreateObject.$updatedAt;
|
673
|
-
delete toCreateObject.$id;
|
674
|
-
delete toCreateObject.$permissions;
|
675
|
-
return tryAwaitWithRetry(async () =>
|
676
|
-
remoteDb.createDocument(
|
677
|
-
toDbId,
|
678
|
-
toCollId,
|
679
|
-
doc.$id,
|
680
|
-
toCreateObject,
|
681
|
-
doc.$permissions
|
682
|
-
)
|
683
|
-
);
|
684
|
-
});
|
685
|
-
await Promise.all(batchedPromises);
|
686
|
-
totalDocumentsTransferred += fromCollDocs.documents.length;
|
687
|
-
while (fromCollDocs.documents.length === 50) {
|
688
|
-
fromCollDocs = await tryAwaitWithRetry(async () =>
|
689
|
-
localDb.listDocuments(fromDbId, fromCollId, [
|
690
|
-
Query.limit(50),
|
691
|
-
Query.cursorAfter(
|
692
|
-
fromCollDocs.documents[fromCollDocs.documents.length - 1].$id
|
693
|
-
),
|
694
|
-
])
|
695
|
-
);
|
696
|
-
const batchedPromises = fromCollDocs.documents.map((doc) => {
|
697
|
-
const toCreateObject: Partial<typeof doc> = {
|
698
|
-
...doc,
|
699
|
-
};
|
700
|
-
delete toCreateObject.$databaseId;
|
701
|
-
delete toCreateObject.$collectionId;
|
702
|
-
delete toCreateObject.$createdAt;
|
703
|
-
delete toCreateObject.$updatedAt;
|
704
|
-
delete toCreateObject.$id;
|
705
|
-
delete toCreateObject.$permissions;
|
706
|
-
return tryAwaitWithRetry(async () =>
|
707
|
-
remoteDb.createDocument(
|
708
|
-
toDbId,
|
709
|
-
toCollId,
|
710
|
-
doc.$id,
|
711
|
-
toCreateObject,
|
712
|
-
doc.$permissions
|
713
|
-
)
|
714
|
-
);
|
715
|
-
});
|
716
|
-
await Promise.all(batchedPromises);
|
717
|
-
totalDocumentsTransferred += fromCollDocs.documents.length;
|
718
|
-
}
|
810
|
+
MessageFormatter.success(message, { prefix: "Transfer" });
|
719
811
|
}
|
720
|
-
MessageFormatter.success(
|
721
|
-
`Total documents transferred from database ${fromDbId} to database ${toDbId} -- collection ${fromCollId} to collection ${toCollId}: ${totalDocumentsTransferred}`,
|
722
|
-
{ prefix: "Transfer" }
|
723
|
-
);
|
724
812
|
};
|
package/src/interactiveCLI.ts
CHANGED
@@ -2052,49 +2052,144 @@ export class InteractiveCLI {
|
|
2052
2052
|
MessageFormatter.info("Starting comprehensive transfer configuration...", { prefix: "Transfer" });
|
2053
2053
|
|
2054
2054
|
try {
|
2055
|
-
//
|
2056
|
-
const
|
2057
|
-
|
2058
|
-
|
2059
|
-
name: "sourceEndpoint",
|
2060
|
-
message: "Enter the source Appwrite endpoint:",
|
2061
|
-
validate: (input) => input.trim() !== "" || "Endpoint cannot be empty",
|
2062
|
-
},
|
2063
|
-
{
|
2064
|
-
type: "input",
|
2065
|
-
name: "sourceProject",
|
2066
|
-
message: "Enter the source project ID:",
|
2067
|
-
validate: (input) => input.trim() !== "" || "Project ID cannot be empty",
|
2068
|
-
},
|
2069
|
-
{
|
2070
|
-
type: "password",
|
2071
|
-
name: "sourceKey",
|
2072
|
-
message: "Enter the source API key:",
|
2073
|
-
validate: (input) => input.trim() !== "" || "API key cannot be empty",
|
2074
|
-
},
|
2075
|
-
]);
|
2055
|
+
// Check if user has an appwrite config for easier setup
|
2056
|
+
const hasAppwriteConfig = this.controller?.config?.appwriteEndpoint &&
|
2057
|
+
this.controller?.config?.appwriteProject &&
|
2058
|
+
this.controller?.config?.appwriteKey;
|
2076
2059
|
|
2077
|
-
|
2078
|
-
|
2079
|
-
|
2080
|
-
|
2081
|
-
|
2082
|
-
|
2083
|
-
|
2084
|
-
|
2085
|
-
|
2086
|
-
|
2087
|
-
|
2088
|
-
|
2089
|
-
|
2090
|
-
|
2091
|
-
{
|
2092
|
-
|
2093
|
-
|
2094
|
-
|
2095
|
-
|
2096
|
-
|
2097
|
-
|
2060
|
+
let sourceConfig: any;
|
2061
|
+
let targetConfig: any;
|
2062
|
+
|
2063
|
+
if (hasAppwriteConfig) {
|
2064
|
+
// Offer to use existing config for source
|
2065
|
+
const { useConfigForSource } = await inquirer.prompt([
|
2066
|
+
{
|
2067
|
+
type: "confirm",
|
2068
|
+
name: "useConfigForSource",
|
2069
|
+
message: "Use your current appwriteConfig as the source?",
|
2070
|
+
default: true,
|
2071
|
+
},
|
2072
|
+
]);
|
2073
|
+
|
2074
|
+
if (useConfigForSource) {
|
2075
|
+
sourceConfig = {
|
2076
|
+
sourceEndpoint: this.controller!.config!.appwriteEndpoint,
|
2077
|
+
sourceProject: this.controller!.config!.appwriteProject,
|
2078
|
+
sourceKey: this.controller!.config!.appwriteKey,
|
2079
|
+
};
|
2080
|
+
MessageFormatter.info(`Using config source: ${sourceConfig.sourceEndpoint}`, { prefix: "Transfer" });
|
2081
|
+
} else {
|
2082
|
+
// Get source configuration manually
|
2083
|
+
sourceConfig = await inquirer.prompt([
|
2084
|
+
{
|
2085
|
+
type: "input",
|
2086
|
+
name: "sourceEndpoint",
|
2087
|
+
message: "Enter the source Appwrite endpoint:",
|
2088
|
+
validate: (input) => input.trim() !== "" || "Endpoint cannot be empty",
|
2089
|
+
},
|
2090
|
+
{
|
2091
|
+
type: "input",
|
2092
|
+
name: "sourceProject",
|
2093
|
+
message: "Enter the source project ID:",
|
2094
|
+
validate: (input) => input.trim() !== "" || "Project ID cannot be empty",
|
2095
|
+
},
|
2096
|
+
{
|
2097
|
+
type: "password",
|
2098
|
+
name: "sourceKey",
|
2099
|
+
message: "Enter the source API key:",
|
2100
|
+
validate: (input) => input.trim() !== "" || "API key cannot be empty",
|
2101
|
+
},
|
2102
|
+
]);
|
2103
|
+
}
|
2104
|
+
|
2105
|
+
// Offer to use existing config for target
|
2106
|
+
const { useConfigForTarget } = await inquirer.prompt([
|
2107
|
+
{
|
2108
|
+
type: "confirm",
|
2109
|
+
name: "useConfigForTarget",
|
2110
|
+
message: "Use your current appwriteConfig as the target?",
|
2111
|
+
default: false,
|
2112
|
+
},
|
2113
|
+
]);
|
2114
|
+
|
2115
|
+
if (useConfigForTarget) {
|
2116
|
+
targetConfig = {
|
2117
|
+
targetEndpoint: this.controller!.config!.appwriteEndpoint,
|
2118
|
+
targetProject: this.controller!.config!.appwriteProject,
|
2119
|
+
targetKey: this.controller!.config!.appwriteKey,
|
2120
|
+
};
|
2121
|
+
MessageFormatter.info(`Using config target: ${targetConfig.targetEndpoint}`, { prefix: "Transfer" });
|
2122
|
+
} else {
|
2123
|
+
// Get target configuration manually
|
2124
|
+
targetConfig = await inquirer.prompt([
|
2125
|
+
{
|
2126
|
+
type: "input",
|
2127
|
+
name: "targetEndpoint",
|
2128
|
+
message: "Enter the target Appwrite endpoint:",
|
2129
|
+
validate: (input) => input.trim() !== "" || "Endpoint cannot be empty",
|
2130
|
+
},
|
2131
|
+
{
|
2132
|
+
type: "input",
|
2133
|
+
name: "targetProject",
|
2134
|
+
message: "Enter the target project ID:",
|
2135
|
+
validate: (input) => input.trim() !== "" || "Project ID cannot be empty",
|
2136
|
+
},
|
2137
|
+
{
|
2138
|
+
type: "password",
|
2139
|
+
name: "targetKey",
|
2140
|
+
message: "Enter the target API key:",
|
2141
|
+
validate: (input) => input.trim() !== "" || "API key cannot be empty",
|
2142
|
+
},
|
2143
|
+
]);
|
2144
|
+
}
|
2145
|
+
} else {
|
2146
|
+
// No appwrite config found, get both configurations manually
|
2147
|
+
MessageFormatter.info("No appwriteConfig found, please enter source and target configurations manually", { prefix: "Transfer" });
|
2148
|
+
|
2149
|
+
// Get source configuration
|
2150
|
+
sourceConfig = await inquirer.prompt([
|
2151
|
+
{
|
2152
|
+
type: "input",
|
2153
|
+
name: "sourceEndpoint",
|
2154
|
+
message: "Enter the source Appwrite endpoint:",
|
2155
|
+
validate: (input) => input.trim() !== "" || "Endpoint cannot be empty",
|
2156
|
+
},
|
2157
|
+
{
|
2158
|
+
type: "input",
|
2159
|
+
name: "sourceProject",
|
2160
|
+
message: "Enter the source project ID:",
|
2161
|
+
validate: (input) => input.trim() !== "" || "Project ID cannot be empty",
|
2162
|
+
},
|
2163
|
+
{
|
2164
|
+
type: "password",
|
2165
|
+
name: "sourceKey",
|
2166
|
+
message: "Enter the source API key:",
|
2167
|
+
validate: (input) => input.trim() !== "" || "API key cannot be empty",
|
2168
|
+
},
|
2169
|
+
]);
|
2170
|
+
|
2171
|
+
// Get target configuration
|
2172
|
+
targetConfig = await inquirer.prompt([
|
2173
|
+
{
|
2174
|
+
type: "input",
|
2175
|
+
name: "targetEndpoint",
|
2176
|
+
message: "Enter the target Appwrite endpoint:",
|
2177
|
+
validate: (input) => input.trim() !== "" || "Endpoint cannot be empty",
|
2178
|
+
},
|
2179
|
+
{
|
2180
|
+
type: "input",
|
2181
|
+
name: "targetProject",
|
2182
|
+
message: "Enter the target project ID:",
|
2183
|
+
validate: (input) => input.trim() !== "" || "Project ID cannot be empty",
|
2184
|
+
},
|
2185
|
+
{
|
2186
|
+
type: "password",
|
2187
|
+
name: "targetKey",
|
2188
|
+
message: "Enter the target API key:",
|
2189
|
+
validate: (input) => input.trim() !== "" || "API key cannot be empty",
|
2190
|
+
},
|
2191
|
+
]);
|
2192
|
+
}
|
2098
2193
|
|
2099
2194
|
// Get transfer options
|
2100
2195
|
const transferOptions = await inquirer.prompt([
|
@@ -2161,17 +2256,19 @@ export class InteractiveCLI {
|
|
2161
2256
|
return;
|
2162
2257
|
}
|
2163
2258
|
|
2164
|
-
//
|
2259
|
+
// Password preservation information
|
2165
2260
|
if (transferOptions.transferTypes.includes("users") && !transferOptions.dryRun) {
|
2166
|
-
MessageFormatter.
|
2167
|
-
MessageFormatter.
|
2261
|
+
MessageFormatter.info("User Password Transfer Information:", { prefix: "Transfer" });
|
2262
|
+
MessageFormatter.info("✅ Users with hashed passwords (Argon2, Bcrypt, Scrypt, MD5, SHA, PHPass) will preserve their passwords", { prefix: "Transfer" });
|
2263
|
+
MessageFormatter.info("⚠️ Users without hash information will receive temporary passwords and need to reset", { prefix: "Transfer" });
|
2264
|
+
MessageFormatter.info("🔒 All user data (preferences, labels, verification status) will be preserved", { prefix: "Transfer" });
|
2168
2265
|
|
2169
2266
|
const { continueWithUsers } = await inquirer.prompt([
|
2170
2267
|
{
|
2171
2268
|
type: "confirm",
|
2172
2269
|
name: "continueWithUsers",
|
2173
|
-
message: "Continue with user transfer
|
2174
|
-
default:
|
2270
|
+
message: "Continue with user transfer?",
|
2271
|
+
default: true,
|
2175
2272
|
},
|
2176
2273
|
]);
|
2177
2274
|
|
@@ -2210,7 +2307,8 @@ export class InteractiveCLI {
|
|
2210
2307
|
} else {
|
2211
2308
|
MessageFormatter.success("Comprehensive transfer completed!", { prefix: "Transfer" });
|
2212
2309
|
if (transferOptions.transferTypes.includes("users") && results.users.transferred > 0) {
|
2213
|
-
MessageFormatter.info("
|
2310
|
+
MessageFormatter.info("Users with preserved password hashes can log in with their original passwords", { prefix: "Transfer" });
|
2311
|
+
MessageFormatter.info("Users with temporary passwords will need to reset their passwords", { prefix: "Transfer" });
|
2214
2312
|
}
|
2215
2313
|
}
|
2216
2314
|
|