Add JAMS option when creating account

With JAMS, we do not use SFL's database to store account credentials. We
rather pass the credentials directly to jami. It internally checks the
credentials with the server, and returns ERROR_GENERIC if it fails.
Multiple jami accounts can be created with the same JAMS account. That's
why I store the credentials in accounts.json too. I splitted it into two
fields: local and jams.

GitLab: #72
Change-Id: Icc925936fb47748133637837462016c4ecbbe79e
diff --git a/common/src/AccountDetails.ts b/common/src/AccountDetails.ts
index 69968c0..2051f90 100644
--- a/common/src/AccountDetails.ts
+++ b/common/src/AccountDetails.ts
@@ -42,6 +42,9 @@
   'Account.presenceSubscribeSupported': string;
   'Account.presenceStatus': string;
   'Account.presenceNote': string;
+  'Account.archivePassword': string;
+  'Account.managerUri': string;
+  'Account.managerUsername': string;
 
   'Account.hostname': string;
   'Account.username': string;
diff --git a/server/src/jamid/jamid.ts b/server/src/jamid/jamid.ts
index 843d7cf..e7107b6 100644
--- a/server/src/jamid/jamid.ts
+++ b/server/src/jamid/jamid.ts
@@ -200,12 +200,12 @@
     this.jamiSwig.setAccountDetails(accountId, accountDetailsStringMap);
   }
 
-  async addAccount(details: Map<string, string | number | boolean>): Promise<string> {
-    const detailsStringMap: StringMap = new this.jamiSwig.StringMap();
+  async addAccount(accountDetails: Partial<AccountDetails>): Promise<RegistrationStateChanged> {
+    accountDetails['Account.type'] = 'RING';
 
-    detailsStringMap.set('Account.type', 'RING');
-    for (const [key, value] of details.entries()) {
-      detailsStringMap.set('Account.' + key, value.toString());
+    const detailsStringMap: StringMap = new this.jamiSwig.StringMap();
+    for (const [key, value] of Object.entries(accountDetails)) {
+      detailsStringMap.set(key, value.toString());
     }
 
     const accountId = this.jamiSwig.addAccount(detailsStringMap);
@@ -214,8 +214,7 @@
         filter((value) => value.accountId === accountId),
         // TODO: is it the only state?
         // TODO: Replace with string enum in common/
-        filter((value) => value.state === 'REGISTERED'),
-        map((value) => value.accountId)
+        filter((value) => value.state === 'REGISTERED' || value.state === 'ERROR_GENERIC')
       )
     );
   }
diff --git a/server/src/routers/auth-router.ts b/server/src/routers/auth-router.ts
index 4255655..01e0ad1 100644
--- a/server/src/routers/auth-router.ts
+++ b/server/src/routers/auth-router.ts
@@ -19,7 +19,7 @@
 import { Router } from 'express';
 import asyncHandler from 'express-async-handler';
 import { ParamsDictionary, Request } from 'express-serve-static-core';
-import { HttpStatusCode } from 'jami-web-common';
+import { AccountDetails, HttpStatusCode } from 'jami-web-common';
 import { Container } from 'typedi';
 
 import { Jamid } from '../jamid/jamid.js';
@@ -29,6 +29,7 @@
 interface Credentials {
   username: string;
   password: string;
+  isJams?: boolean;
 }
 
 const jamid = Container.get(Jamid);
@@ -39,12 +40,18 @@
 authRouter.post(
   '/new-account',
   asyncHandler(async (req: Request<ParamsDictionary, string, Partial<Credentials>>, res, _next) => {
-    const { username, password } = req.body;
+    const { username, password, isJams } = req.body;
     if (username === undefined || password === undefined) {
       res.status(HttpStatusCode.BadRequest).send('Missing username or password in body');
       return;
     }
 
+    const isAccountAlreadyCreated = accounts.get(username, isJams) !== undefined;
+    if (isAccountAlreadyCreated) {
+      res.status(HttpStatusCode.Conflict).send('Username already exists locally');
+      return;
+    }
+
     if (password === '') {
       res.status(HttpStatusCode.BadRequest).send('Password may not be empty');
       return;
@@ -52,25 +59,35 @@
 
     const hashedPassword = await argon2.hash(password, { type: argon2.argon2id });
 
-    // TODO: add JAMS support
-    // managerUri: 'https://jams.savoirfairelinux.com',
-    // managerUsername: data.username,
-    const accountId = await jamid.addAccount(new Map());
+    const accountDetails: Partial<AccountDetails> = { 'Account.archivePassword': password };
+    if (isJams) {
+      accountDetails['Account.managerUri'] = 'https://jams.savoirfairelinux.com/';
+      accountDetails['Account.managerUsername'] = username;
+    }
+    const { accountId, state } = await jamid.addAccount(accountDetails);
 
-    const state = await jamid.registerUsername(accountId, username, '');
-    if (state !== 0) {
-      jamid.removeAccount(accountId);
-      if (state === 2) {
-        res.status(HttpStatusCode.BadRequest).send('Invalid username or password');
-      } else if (state === 3) {
-        res.status(HttpStatusCode.Conflict).send('Username already exists');
-      } else {
-        throw new Error(`Unhandled state ${state}`);
+    if (isJams) {
+      if (state === 'ERROR_GENERIC') {
+        jamid.removeAccount(accountId);
+        res.status(HttpStatusCode.Unauthorized).send('Invalid JAMS credentials');
+        return;
       }
-      return;
+    } else {
+      const state = await jamid.registerUsername(accountId, username, '');
+      if (state !== 0) {
+        jamid.removeAccount(accountId);
+        if (state === 2) {
+          res.status(HttpStatusCode.BadRequest).send('Invalid username or password');
+        } else if (state === 3) {
+          res.status(HttpStatusCode.Conflict).send('Username already exists');
+        } else {
+          throw new Error(`Unhandled state ${state}`);
+        }
+        return;
+      }
     }
 
-    accounts.set(username, hashedPassword);
+    accounts.set(username, hashedPassword, isJams);
     await accounts.save();
 
     res.sendStatus(HttpStatusCode.Created);
@@ -81,7 +98,7 @@
   '/login',
   asyncHandler(
     async (req: Request<ParamsDictionary, { accessToken: string } | string, Partial<Credentials>>, res, _next) => {
-      const { username, password } = req.body;
+      const { username, password, isJams } = req.body;
       if (username === undefined || password === undefined) {
         res.status(HttpStatusCode.BadRequest).send('Missing username or password in body');
         return;
@@ -94,7 +111,7 @@
         return;
       }
 
-      const hashedPassword = accounts.get(username);
+      const hashedPassword = accounts.get(username, isJams);
       if (hashedPassword === undefined) {
         res
           .status(HttpStatusCode.NotFound)
diff --git a/server/src/storage/accounts.ts b/server/src/storage/accounts.ts
index 77a8d9d..efcd014 100644
--- a/server/src/storage/accounts.ts
+++ b/server/src/storage/accounts.ts
@@ -20,10 +20,15 @@
 
 import { Service } from 'typedi';
 
+interface AccountsFormat {
+  local: Record<string, string>;
+  jams: Record<string, string>;
+}
+
 @Service()
 export class Accounts {
   private readonly filename = 'accounts.json';
-  private accounts: Record<string, string>;
+  private accounts: AccountsFormat;
 
   constructor() {
     let buffer: Buffer;
@@ -31,18 +36,18 @@
     try {
       buffer = readFileSync(this.filename);
     } catch (e) {
-      buffer = Buffer.from('{}');
+      buffer = Buffer.from('{"local":{}},"jams":{}}}');
     }
 
     this.accounts = JSON.parse(buffer.toString());
   }
 
-  get(username: string): string | undefined {
-    return this.accounts[username];
+  get(username: string, isJams = false): string | undefined {
+    return this.accounts[isJams ? 'jams' : 'local'][username];
   }
 
-  set(username: string, password: string): void {
-    this.accounts[username] = password;
+  set(username: string, password: string, isJams = false): void {
+    this.accounts[isJams ? 'jams' : 'local'][username] = password;
   }
 
   async save(): Promise<void> {