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
+ <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();
+ }
+}