Add support for auth tokens

Change-Id: Ifa16a75bec09898656ede82a1bcba113d788f166
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/AuthTokens.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/AuthTokens.java
new file mode 100644
index 0000000..71b9d6e
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/AuthTokens.java
@@ -0,0 +1,66 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.serviceuser;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.account.AuthToken;
+import com.google.gerrit.server.account.AuthTokenAccessor;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+class AuthTokens implements ChildCollection<ServiceUserResource, ServiceUserResource.Token> {
+  private final DynamicMap<RestView<ServiceUserResource.Token>> views;
+  private final Provider<GetTokens> list;
+  private final AuthTokenAccessor tokenAccessor;
+
+  @Inject
+  AuthTokens(
+      DynamicMap<RestView<ServiceUserResource.Token>> views,
+      Provider<GetTokens> list,
+      AuthTokenAccessor tokenAccessor) {
+    this.views = views;
+    this.list = list;
+    this.tokenAccessor = tokenAccessor;
+  }
+
+  @Override
+  public RestView<ServiceUserResource> list() {
+    return list.get();
+  }
+
+  @Override
+  public ServiceUserResource.Token parse(ServiceUserResource parent, IdString id)
+      throws ResourceNotFoundException, IOException, ConfigInvalidException {
+    Optional<AuthToken> token = tokenAccessor.getToken(parent.getUser().getAccountId(), id.get());
+    if (token.isEmpty()) {
+      throw new ResourceNotFoundException(id);
+    }
+    return new ServiceUserResource.Token(parent.getUser(), token.get());
+  }
+
+  @Override
+  public DynamicMap<RestView<ServiceUserResource.Token>> views() {
+    return views;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/CreateToken.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/CreateToken.java
new file mode 100644
index 0000000..dd41fc3
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/CreateToken.java
@@ -0,0 +1,78 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.googlesource.gerrit.plugins.serviceuser;
+
+import static com.google.gerrit.server.permissions.GlobalPermission.ADMINISTRATE_SERVER;
+
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.extensions.auth.AuthTokenInfo;
+import com.google.gerrit.extensions.auth.AuthTokenInput;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.InvalidAuthTokenException;
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.config.PluginConfigFactory;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class CreateToken
+    implements RestCollectionCreateView<
+        ServiceUserResource, ServiceUserResource.Token, AuthTokenInput> {
+  private final PluginConfig config;
+  private final com.google.gerrit.server.restapi.account.CreateToken createToken;
+  private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  CreateToken(
+      @PluginName String pluginName,
+      PluginConfigFactory pluginConfigFactory,
+      com.google.gerrit.server.restapi.account.CreateToken createToken,
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend) {
+    this.config = pluginConfigFactory.getFromGerritConfig(pluginName);
+    this.createToken = createToken;
+    this.self = self;
+    this.permissionBackend = permissionBackend;
+  }
+
+  @Override
+  public Response<AuthTokenInfo> apply(ServiceUserResource rsrc, IdString id, AuthTokenInput input)
+      throws ConfigInvalidException,
+          IOException,
+          PermissionBackendException,
+          RestApiException,
+          RuntimeException,
+          InvalidAuthTokenException {
+    if (!config.getBoolean("allowHttpPassword", false)) {
+      permissionBackend.user(self.get()).check(ADMINISTRATE_SERVER);
+    } else if (input.token != null) {
+      if (!config.getBoolean("allowCustomHttpPassword", false)) {
+        permissionBackend.user(self.get()).check(ADMINISTRATE_SERVER);
+      }
+    }
+
+    return createToken.apply(new AccountResource(rsrc.getUser()), id, input);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/DeleteToken.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/DeleteToken.java
new file mode 100644
index 0000000..bc38f48
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/DeleteToken.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.googlesource.gerrit.plugins.serviceuser;
+
+import static com.google.gerrit.server.permissions.GlobalPermission.ADMINISTRATE_SERVER;
+
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.extensions.common.Input;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.InvalidAuthTokenException;
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.config.PluginConfigFactory;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class DeleteToken implements RestModifyView<ServiceUserResource.Token, Input> {
+  private final PluginConfig config;
+  private final com.google.gerrit.server.restapi.account.DeleteToken deleteToken;
+  private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  DeleteToken(
+      @PluginName String pluginName,
+      PluginConfigFactory pluginConfigFactory,
+      com.google.gerrit.server.restapi.account.DeleteToken deleteToken,
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend) {
+    this.config = pluginConfigFactory.getFromGerritConfig(pluginName);
+    this.deleteToken = deleteToken;
+    this.self = self;
+    this.permissionBackend = permissionBackend;
+  }
+
+  @Override
+  public Response<String> apply(ServiceUserResource.Token rsrc, Input input)
+      throws ConfigInvalidException,
+          IOException,
+          PermissionBackendException,
+          RestApiException,
+          RuntimeException,
+          InvalidAuthTokenException {
+    if (!config.getBoolean("allowHttpPassword", false)) {
+      permissionBackend.user(self.get()).check(ADMINISTRATE_SERVER);
+    }
+
+    return deleteToken.apply(rsrc.getUser(), rsrc.getToken().id(), false);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/GetTokens.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/GetTokens.java
new file mode 100644
index 0000000..fb56d77
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/GetTokens.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.googlesource.gerrit.plugins.serviceuser;
+
+import static com.google.gerrit.server.permissions.GlobalPermission.ADMINISTRATE_SERVER;
+
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.extensions.auth.AuthTokenInfo;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.InvalidAuthTokenException;
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.config.PluginConfigFactory;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class GetTokens implements RestReadView<ServiceUserResource> {
+  private final PluginConfig config;
+  private final com.google.gerrit.server.restapi.account.GetTokens getTokens;
+  private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  GetTokens(
+      @PluginName String pluginName,
+      PluginConfigFactory pluginConfigFactory,
+      com.google.gerrit.server.restapi.account.GetTokens getTokens,
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend) {
+    this.config = pluginConfigFactory.getFromGerritConfig(pluginName);
+    this.getTokens = getTokens;
+    this.self = self;
+    this.permissionBackend = permissionBackend;
+  }
+
+  @Override
+  public Response<List<AuthTokenInfo>> apply(ServiceUserResource rsrc)
+      throws ConfigInvalidException,
+          IOException,
+          PermissionBackendException,
+          RestApiException,
+          RuntimeException,
+          InvalidAuthTokenException {
+    if (!config.getBoolean("allowHttpPassword", false)) {
+      permissionBackend.user(self.get()).check(ADMINISTRATE_SERVER);
+    }
+
+    return Response.ok(getTokens.apply(rsrc.getUser()));
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/Module.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/Module.java
index 1baa0ee..fbfc1c0 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/Module.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/Module.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.server.config.ConfigResource.CONFIG_KIND;
 import static com.googlesource.gerrit.plugins.serviceuser.ServiceUserResource.SERVICE_USER_KIND;
 import static com.googlesource.gerrit.plugins.serviceuser.ServiceUserResource.SERVICE_USER_SSH_KEY_KIND;
+import static com.googlesource.gerrit.plugins.serviceuser.ServiceUserResource.SERVICE_USER_TOKEN_KIND;
 
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.annotations.PluginName;
@@ -51,6 +52,7 @@
           protected void configure() {
             DynamicMap.mapOf(binder(), SERVICE_USER_KIND);
             DynamicMap.mapOf(binder(), SERVICE_USER_SSH_KEY_KIND);
+            DynamicMap.mapOf(binder(), SERVICE_USER_TOKEN_KIND);
             bind(ServiceUserCollection.class);
             child(CONFIG_KIND, "serviceusers").to(ServiceUserCollection.class);
             create(SERVICE_USER_KIND).to(CreateServiceUser.class);
@@ -67,8 +69,9 @@
             get(SERVICE_USER_KIND, "email").to(GetEmail.class);
             put(SERVICE_USER_KIND, "email").to(PutEmail.class);
             delete(SERVICE_USER_KIND, "email").to(PutEmail.class);
-            put(SERVICE_USER_KIND, "password.http").to(PutHttpPassword.class);
-            delete(SERVICE_USER_KIND, "password.http").to(PutHttpPassword.class);
+            child(SERVICE_USER_KIND, "tokens").to(AuthTokens.class);
+            create(SERVICE_USER_TOKEN_KIND).to(CreateToken.class);
+            delete(SERVICE_USER_TOKEN_KIND).to(DeleteToken.class);
             get(SERVICE_USER_KIND, "active").to(GetActive.class);
             put(SERVICE_USER_KIND, "active").to(PutActive.class);
             delete(SERVICE_USER_KIND, "active").to(DeleteActive.class);
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/PutHttpPassword.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/PutHttpPassword.java
deleted file mode 100644
index 16cfab8..0000000
--- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/PutHttpPassword.java
+++ /dev/null
@@ -1,87 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-package com.googlesource.gerrit.plugins.serviceuser;
-
-import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
-import static com.google.gerrit.server.permissions.GlobalPermission.ADMINISTRATE_SERVER;
-import static com.google.gerrit.server.restapi.account.PutHttpPassword.generate;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.config.ConfigResource;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import com.googlesource.gerrit.plugins.serviceuser.GetConfig.ConfigInfo;
-import com.googlesource.gerrit.plugins.serviceuser.PutHttpPassword.Input;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-@Singleton
-public class PutHttpPassword implements RestModifyView<ServiceUserResource, Input> {
-  public static class Input {
-    public String httpPassword;
-    public boolean generate;
-  }
-
-  private final Provider<GetConfig> getConfig;
-  private final com.google.gerrit.server.restapi.account.PutHttpPassword putHttpPassword;
-  private final Provider<CurrentUser> self;
-  private final PermissionBackend permissionBackend;
-
-  @Inject
-  PutHttpPassword(
-      Provider<GetConfig> getConfig,
-      com.google.gerrit.server.restapi.account.PutHttpPassword putHttpPassword,
-      Provider<CurrentUser> self,
-      PermissionBackend permissionBackend) {
-    this.getConfig = getConfig;
-    this.putHttpPassword = putHttpPassword;
-    this.self = self;
-    this.permissionBackend = permissionBackend;
-  }
-
-  @Override
-  public Response<String> apply(ServiceUserResource rsrc, Input input)
-      throws ConfigInvalidException, IOException, PermissionBackendException, RestApiException,
-          RuntimeException {
-    if (input == null) {
-      input = new Input();
-    }
-    input.httpPassword = Strings.emptyToNull(input.httpPassword);
-
-    ConfigInfo config;
-    try {
-      config = getConfig.get().apply(new ConfigResource()).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get configuration", e);
-    }
-
-    if ((config.allowHttpPassword == null || !config.allowHttpPassword)) {
-      permissionBackend.user(self.get()).check(ADMINISTRATE_SERVER);
-    } else if (!input.generate && input.httpPassword != null) {
-      if ((config.allowCustomHttpPassword == null || !config.allowCustomHttpPassword)) {
-        permissionBackend.user(self.get()).check(ADMINISTRATE_SERVER);
-      }
-    }
-
-    String newPassword = input.generate ? generate() : input.httpPassword;
-    return putHttpPassword.apply(rsrc.getUser(), newPassword);
-  }
-}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/ServiceUserResource.java b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/ServiceUserResource.java
index ffcc9f6..5e43f35 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/serviceuser/ServiceUserResource.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/serviceuser/ServiceUserResource.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AccountSshKey;
+import com.google.gerrit.server.account.AuthToken;
 import com.google.inject.TypeLiteral;
 
 class ServiceUserResource extends AccountResource {
@@ -27,6 +28,9 @@
   static final TypeLiteral<RestView<SshKey>> SERVICE_USER_SSH_KEY_KIND =
       new TypeLiteral<RestView<SshKey>>() {};
 
+  static final TypeLiteral<RestView<Token>> SERVICE_USER_TOKEN_KIND =
+      new TypeLiteral<RestView<Token>>() {};
+
   ServiceUserResource(IdentifiedUser user) {
     super(user);
   }
@@ -48,4 +52,17 @@
       return sshKey;
     }
   }
+
+  static class Token extends ServiceUserResource {
+    private final AuthToken token;
+
+    Token(IdentifiedUser user, AuthToken token) {
+      super(user);
+      this.token = token;
+    }
+
+    AuthToken getToken() {
+      return token;
+    }
+  }
 }
diff --git a/web/gr-serviceuser-detail.ts b/web/gr-serviceuser-detail.ts
index 6558dc4..1fda31d 100644
--- a/web/gr-serviceuser-detail.ts
+++ b/web/gr-serviceuser-detail.ts
@@ -24,10 +24,10 @@
 import {AccountCapabilityInfo} from './plugin';
 import {ConfigInfo, ServiceUserInfo} from './gr-serviceuser-create';
 import {GrServiceUserSshPanel} from './gr-serviceuser-ssh-panel';
-import {GrServiceUserHttpPassword} from './gr-serviceuser-http-password';
+import {GrServiceUserTokens} from './gr-serviceuser-tokens';
 
 import './gr-serviceuser-ssh-panel';
-import './gr-serviceuser-http-password';
+import './gr-serviceuser-tokens';
 
 const NOT_FOUND_MESSAGE = 'Not Found';
 
@@ -37,7 +37,7 @@
   sshEditor!: GrServiceUserSshPanel;
 
   @query('#httpPass')
-  httpPass!: GrServiceUserHttpPassword;
+  httpPass!: GrServiceUserTokens;
 
   @query('#serviceUserFullNameInput')
   serviceUserFullNameInput!: HTMLInputElement;
@@ -309,7 +309,7 @@
         <fieldset>
           <h3 id="HTTPCredentials">HTTP Credentials</h3>
           <fieldset>
-            <gr-serviceuser-http-password id="httpPass">
+            <gr-serviceuser-tokens id="httpPass">
               </gr-http-password>
           </fieldset>
         </fieldset>
diff --git a/web/gr-serviceuser-http-password.ts b/web/gr-serviceuser-http-password.ts
deleted file mode 100644
index aba9f8c..0000000
--- a/web/gr-serviceuser-http-password.ts
+++ /dev/null
@@ -1,144 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {customElement, property, query} from 'lit/decorators.js';
-import {css, CSSResult, html, LitElement} from 'lit';
-import {RestPluginApi} from '@gerritcodereview/typescript-api/rest';
-
-@customElement('gr-serviceuser-http-password')
-export class GrServiceUserHttpPassword extends LitElement {
-  @query('#generatedPasswordModal')
-  generatedPasswordModal?: HTMLDialogElement;
-
-  @property()
-  pluginRestApi!: RestPluginApi;
-
-  @property({type: String})
-  serviceUserId?: String;
-
-  @property({type: String})
-  generatedPassword?: String;
-
-  loadData(pluginRestApi: RestPluginApi) {
-    this.pluginRestApi = pluginRestApi;
-    this.serviceUserId = this.baseURI.split('/').pop();
-  }
-
-  static override get styles() {
-    return [
-      window.Gerrit.styles.font as CSSResult,
-      window.Gerrit.styles.form as CSSResult,
-      window.Gerrit.styles.modal as CSSResult,
-      css`
-        .password {
-          font-family: var(--monospace-font-family);
-          font-size: var(--font-size-mono);
-          line-height: var(--line-height-mono);
-        }
-        #generatedPasswordModal {
-          padding: var(--spacing-xxl);
-          width: 50em;
-        }
-        #generatedPasswordDisplay {
-          margin: var(--spacing-l) 0;
-        }
-        #generatedPasswordDisplay .title {
-          width: unset;
-        }
-        #generatedPasswordDisplay .value {
-          font-family: var(--monospace-font-family);
-          font-size: var(--font-size-mono);
-          line-height: var(--line-height-mono);
-        }
-        #passwordWarning {
-          font-style: italic;
-          text-align: center;
-        }
-        .closeButton {
-          bottom: 2em;
-          position: absolute;
-          right: 2em;
-        }
-      `,
-    ];
-  }
-
-  override render() {
-    return html` <div class="gr-form-styles">
-        <div>
-          <gr-button id="generateButton" @click=${this.handleGenerateTap}
-            >Generate new password</gr-button
-          >
-          <gr-button id="deleteButton" @click="${this.handleDelete}"
-            >Delete password</gr-button
-          >
-        </div>
-      </div>
-      <dialog
-        tabindex="-1"
-        id="generatedPasswordModal"
-        @closed=${this.generatedPasswordModalClosed}
-      >
-        <div class="gr-form-styles">
-          <section id="generatedPasswordDisplay">
-            <span class="title">New Password:</span>
-            <span class="value">${this.generatedPassword}</span>
-            <gr-copy-clipboard
-              hasTooltip=""
-              buttonTitle="Copy password to clipboard"
-              hideInput=""
-              .text=${this.generatedPassword}
-            >
-            </gr-copy-clipboard>
-          </section>
-          <section id="passwordWarning">
-            This password will not be displayed again.<br />
-            If you lose it, you will need to generate a new one.
-          </section>
-          <gr-button link="" class="closeButton" @click=${this.closeModal}
-            >Close</gr-button
-          >
-        </div>
-      </dialog>`;
-  }
-
-  private handleGenerateTap() {
-    this.generatedPassword = 'Generating...';
-    this.generatedPasswordModal?.showModal();
-    this.pluginRestApi
-      .put<String>(`/a/config/server/serviceuser~serviceusers/${this.serviceUserId}/password.http`, {
-        generate: true,
-      })
-      .then(newPassword => {
-        this.generatedPassword = newPassword;
-      });
-  }
-
-  private closeModal() {
-    this.generatedPasswordModal?.close();
-  }
-
-  private generatedPasswordModalClosed() {
-    this.generatedPassword = '';
-  }
-
-  private handleDelete() {
-    this.pluginRestApi.delete(
-      `/a/config/server/serviceuser~serviceusers/${this.serviceUserId}/password.http`
-    );
-  }
-}
diff --git a/web/gr-serviceuser-tokens.ts b/web/gr-serviceuser-tokens.ts
new file mode 100644
index 0000000..0978b33
--- /dev/null
+++ b/web/gr-serviceuser-tokens.ts
@@ -0,0 +1,377 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {css, html, CSSResult, LitElement} from 'lit';
+import {RestPluginApi} from '@gerritcodereview/typescript-api/rest';
+import {Timestamp} from '@gerritcodereview/typescript-api/rest-api';
+
+export interface BindValueChangeEventDetail {
+  value: string | undefined;
+}
+export type BindValueChangeEvent = CustomEvent<BindValueChangeEventDetail>;
+
+export interface TokenInfo {
+  id: string;
+  token?: string;
+  expiration?: Timestamp;
+}
+
+//TODO(Thomas): Remove after updated Typescript API was released with Gerrit 3.13
+export declare interface ServerInfo {
+  auth: AuthInfo;
+}
+
+export declare interface AuthInfo {
+  max_token_lifetime?: string;
+}
+
+@customElement('gr-serviceuser-tokens')
+export class GrServiceUserTokens extends LitElement {
+  @property()
+  pluginRestApi!: RestPluginApi;
+
+  @property({type: String})
+  serviceUserId?: string;  @query('#generatedAuthTokenModal')
+
+  @query('#generatedAuthTokenModal')
+  generatedAuthTokenModal?: HTMLDialogElement;
+
+  @state()
+  username?: string;
+
+  @state()
+  generatedAuthToken?: TokenInfo;
+
+  @state()
+  status?: string;
+
+  @state()
+  maxLifetime: string = 'unlimited';
+
+  @property({type: Array})
+  tokens: TokenInfo[] = [];
+
+  @property({type: String})
+  newTokenId = '';
+
+  @property({type: String})
+  newLifetime = '';
+
+  @query('#generateButton') generateButton!: HTMLButtonElement;
+
+  @query('#newToken') tokenInput!: HTMLInputElement;
+
+  @query('#lifetime') tokenLifetime!: HTMLInputElement;
+
+  async loadData(pluginRestApi: RestPluginApi) {
+    this.pluginRestApi = pluginRestApi;
+    this.serviceUserId = this.baseURI.split('/').pop();
+    await pluginRestApi
+      .get<ServerInfo>('/a/config/server/info')
+      .then(config => {
+        this.maxLifetime = config?.auth?.max_token_lifetime || 'unlimited';
+      });
+    await this.fetchTokens();
+  }
+
+  private fetchTokens() {
+    this.pluginRestApi
+      .get<TokenInfo[]>(`/a/config/server/serviceuser~serviceusers/${this.serviceUserId}/tokens`)
+      .then(tokens => {
+        this.tokens = tokens;
+      });
+  }
+
+  static override get styles() {
+    return [
+      window.Gerrit.styles.form as CSSResult,
+      window.Gerrit.styles.modal as CSSResult,
+      css`
+        .token {
+          font-family: var(--monospace-font-family);
+          font-size: var(--font-size-mono);
+          line-height: var(--line-height-mono);
+        }
+        #generatedAuthTokenModal {
+          padding: var(--spacing-xxl);
+          width: 50em;
+        }
+        #generatedAuthTokenDisplay {
+          margin: var(--spacing-l) 0;
+        }
+        #generatedAuthTokenDisplay .title {
+          width: unset;
+        }
+        #generatedAuthTokenDisplay .value {
+          font-family: var(--monospace-font-family);
+          font-size: var(--font-size-mono);
+          line-height: var(--line-height-mono);
+        }
+        #authTokenWarning {
+          font-style: italic;
+          text-align: center;
+        }
+        #existing {
+          margin-top: var(--spacing-l);
+          margin-bottom: var(--spacing-l);
+        }
+        #existing .idColumn {
+          min-width: 15em;
+          width: auto;
+        }
+        .closeButton {
+          bottom: 2em;
+          position: absolute;
+          right: 2em;
+        }
+        .expired {
+          color: var(--negative-red-text-color);
+        }
+        .lifeTimeInput {
+          min-width: 23em;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <div class="gr-form-styles">
+        <fieldset id="existing">
+          <table>
+            <thead>
+              <tr>
+                <th class="idColumn">ID</th>
+                <th class="expirationColumn">Expiration Date</th>
+                <th></th>
+              </tr>
+            </thead>
+            <tbody>
+              ${this.tokens.map(tokenInfo => this.renderToken(tokenInfo))}
+            </tbody>
+            <tfoot>
+              ${this.renderFooterRow()}
+            </tfoot>
+          </table>
+        </fieldset>
+      </div>
+      <dialog
+        tabindex="-1"
+        id="generatedAuthTokenModal"
+        @closed=${this.generatedAuthTokenModalClosed}
+      >
+        <div class="gr-form-styles">
+          <section id="generatedAuthTokenDisplay">
+            <span class="title">New Token:</span>
+            <span class="value"
+              >${this.status || this.generatedAuthToken?.token}</span
+            >
+            <gr-copy-clipboard
+              hasTooltip=""
+              buttonTitle="Copy token to clipboard"
+              hideInput=""
+              .text=${this.status ? '' : this.generatedAuthToken?.token}
+            >
+            </gr-copy-clipboard>
+          </section>
+          <section
+            id="authTokenWarning"
+            ?hidden=${!this.generatedAuthToken?.expiration}
+          >
+            This token will be valid until &nbsp;
+            <gr-date-formatter
+              showDateAndTime
+              withTooltip
+              .dateStr=${this.generatedAuthToken?.expiration}
+            ></gr-date-formatter>
+            .
+          </section>
+          <section id="authTokenWarning">
+            This token will not be displayed again.<br />
+            If you lose it, you will need to generate a new one.
+          </section>
+          <gr-button link="" class="closeButton" @click=${this.closeModal}
+            >Close</gr-button
+          >
+        </div>
+      </dialog>`;
+  }
+
+  private renderToken(tokenInfo: TokenInfo) {
+    return html` <tr class=${this.isTokenExpired(tokenInfo) ? 'expired' : ''}>
+      <td class="idColumn">${tokenInfo.id}</td>
+      <td class="expirationColumn">
+        <gr-date-formatter
+          withTooltip
+          showDateAndTime
+          dateFormat="STD"
+          .dateStr=${tokenInfo.expiration}
+        ></gr-date-formatter>
+      </td>
+      <td>
+        <gr-button
+          id="deleteButton"
+          @click=${() => this.handleDeleteTap(tokenInfo.id)}
+          >Delete</gr-button
+        >
+      </td>
+    </tr>`;
+  }
+
+  private renderFooterRow() {
+    return html`
+      <tr>
+        <th style="vertical-align: top;">
+          <iron-input
+            id="newToken"
+            .bindValue=${this.newTokenId}
+            @bind-value-changed=${(e: BindValueChangeEvent) => {
+              this.newTokenId = e.detail.value ?? '';
+            }}
+          >
+            <input
+              is="iron-input"
+              placeholder="New Token ID"
+              @keydown=${this.handleInputKeydown}
+            />
+          </iron-input>
+        </th>
+        <th style="vertical-align: top;">
+          <iron-input
+            .bindValue=${this.newLifetime}
+            @bind-value-changed=${(e: BindValueChangeEvent) => {
+              this.newLifetime = e.detail.value ?? '';
+            }}
+          >
+            <input
+              class="lifeTimeInput"
+              is="iron-input"
+              placeholder="Lifetime (e.g. 30d)"
+              @keydown=${this.handleInputKeydown}
+            />
+          </iron-input></br>
+          (Max. allowed lifetime: ${this.formatDuration(this.maxLifetime)})
+        </th>
+        <th>
+          <gr-button
+            id="generateButton"
+            link=""
+            ?disabled=${!this.newTokenId.length}
+            @click=${this.handleGenerateTap}
+            >Generate</gr-button
+          >
+        </th>
+      </tr>
+    `;
+  }
+
+  private formatDuration(durationMinutes: string) {
+    if (!durationMinutes) return '';
+    if (durationMinutes === 'unlimited') return 'unlimited';
+    let minutes = parseInt(durationMinutes, 10);
+    let hours = Math.floor(minutes / 60);
+    minutes = minutes % 60;
+    let days = Math.floor(hours / 24);
+    hours = hours % 24;
+    const years = Math.floor(days / 365);
+    days = days % 365;
+    let formatted = '';
+    if (years) formatted += `${years}y `;
+    if (days) formatted += `${days}d `;
+    if (hours) formatted += `${hours}h `;
+    if (minutes) formatted += `${minutes}m`;
+    return formatted;
+  }
+
+  private isTokenExpired(tokenInfo: TokenInfo) {
+    if (!tokenInfo.expiration) return false;
+    return new Date(tokenInfo.expiration.replace(' ', 'T') + 'Z') < new Date();
+  }
+
+  private handleInputKeydown(e: KeyboardEvent) {
+    if (e.key === 'Enter') {
+      e.stopPropagation();
+      this.handleGenerateTap();
+    }
+  }
+
+  private handleGenerateTap() {
+    this.generateButton.disabled = true;
+    this.status = 'Generating...';
+    this.generatedAuthTokenModal?.showModal();
+    this.pluginRestApi
+      .put<TokenInfo>(
+        `/a/config/server/serviceuser~serviceusers/${this.serviceUserId}/tokens/${this.newTokenId}`,
+        {
+          id: this.newTokenId,
+          lifetime: this.newLifetime,
+        },
+      )
+      .catch(err => {
+        this.closeModal();
+        this.dispatchEvent(
+          new CustomEvent('show-error', {
+            detail: {message: err},
+            composed: true,
+            bubbles: true,
+          })
+        );
+      })
+      .then(newToken => {
+        if (newToken) {
+          this.generatedAuthToken = newToken;
+          this.status = undefined;
+          this.fetchTokens();
+          this.tokenInput.value = '';
+          this.tokenLifetime.value = '';
+        } else {
+          this.status = 'Failed to generate';
+        }
+      })
+      .finally(() => {
+        this.generateButton.disabled = false;
+      });
+  }
+
+  private handleDeleteTap(id: string) {
+    this.pluginRestApi
+      .delete(
+        `/a/config/server/serviceuser~serviceusers/${this.serviceUserId}/tokens/${id}`)
+      .catch(err => {
+        this.dispatchEvent(
+          new CustomEvent('show-error', {
+            detail: {message: err},
+            composed: true,
+            bubbles: true,
+          })
+        );
+      })
+      .then(() => {
+        this.fetchTokens();
+      });
+  }
+
+  private generatedAuthTokenModalClosed() {
+    this.status = undefined;
+    this.generatedAuthToken = undefined;
+  }
+
+  private closeModal() {
+    this.generatedAuthTokenModal?.close();
+  }
+}