mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-20 22:39:08 +00:00
3bec08f998
Adds first-class bot accounts (`UserTypeBot`): local, password-less
users for automation that authenticate only with access tokens.
1. Admin UI: create bots, filter users by type, manage a bot's access
tokens, convert between user and bot
2. API: `POST /admin/users/{username}/convert-type`, and user objects
gain a GitHub-compatible `type` (`User`, `Organization`, `Bot`)
3. CLI: `gitea admin user change-type`, `--user-type` accepts `User` or
`Bot` case-insensitively
4. Converting keeps the password, 2FA, OAuth2 grants and access tokens,
and since sign-in rejects bots, converting back restores the account.
Only local, non-admin accounts can be converted, and conversions are
audited
5. Session, reverse proxy, SSPI, external source and password reset
sign-in reject non-individual users, so a bot never gets an interactive
session
6. Bots receive no notifications or emails
Co-authored-by: Nicolas <bircni@icloud.com>
Co-authored-by: joestump <joe@joestump.net>
Co-authored-by: Joe Stump <joe@stu.mp>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
324 lines
14 KiB
TypeScript
324 lines
14 KiB
TypeScript
import {checkAppUrl} from '../common-page.ts';
|
|
import {hideElem, queryElems, showElem, toggleElem} from '../../utils/dom.ts';
|
|
import {POST} from '../../modules/fetch.ts';
|
|
import {showFomanticModal} from '../../modules/fomantic/modal.ts';
|
|
import {pathEscape} from '../../utils/url.ts';
|
|
import {registerGlobalInitFunc} from '../../modules/observer.ts';
|
|
|
|
const {appSubUrl} = window.config;
|
|
|
|
function onSecurityProtocolChange(): void {
|
|
if (Number(document.querySelector<HTMLInputElement>('#security_protocol')?.value) > 0) {
|
|
showElem('.has-tls');
|
|
} else {
|
|
hideElem('.has-tls');
|
|
}
|
|
}
|
|
|
|
export function initAdminCommon(): void {
|
|
if (!document.querySelector('.page-content.admin')) return;
|
|
|
|
// check whether appUrl(ROOT_URL) is correct, if not, show an error message
|
|
checkAppUrl();
|
|
|
|
initAdminUser();
|
|
initAdminAuthentication();
|
|
initAdminNotice();
|
|
registerGlobalInitFunc('initRunnerBulkToolbar', initAdminRunnerBulk);
|
|
}
|
|
|
|
function initAdminRunnerBulk(toolbar: HTMLElement) {
|
|
const actionButtons = toolbar.querySelectorAll<HTMLButtonElement>('.runner-bulk-action');
|
|
const formRunnerIds = toolbar.querySelector<HTMLInputElement>('form input[name="ids"]')!;
|
|
const rowCheckboxes = document.querySelectorAll<HTMLInputElement>('.runner-bulk-select');
|
|
const selectAll = document.querySelector<HTMLInputElement>('.runner-bulk-select-all');
|
|
if (!selectAll) return;
|
|
|
|
const refresh = () => {
|
|
const checked = Array.from(rowCheckboxes).filter((c) => c.checked);
|
|
formRunnerIds.value = checked.map((c) => c.getAttribute('data-runner-id')!).join(',');
|
|
toggleElem(toolbar, checked.length > 0);
|
|
for (const btn of actionButtons) {
|
|
btn.querySelector<HTMLElement>('.runner-bulk-count')!.textContent = `(${checked.length})`;
|
|
}
|
|
selectAll.checked = checked.length > 0 && checked.length === rowCheckboxes.length;
|
|
selectAll.indeterminate = checked.length > 0 && checked.length < rowCheckboxes.length;
|
|
};
|
|
|
|
selectAll.addEventListener('change', () => {
|
|
for (const cb of rowCheckboxes) cb.checked = selectAll.checked;
|
|
refresh();
|
|
});
|
|
for (const cb of rowCheckboxes) cb.addEventListener('change', refresh);
|
|
refresh();
|
|
}
|
|
|
|
function initAdminUser() {
|
|
const pageContent = document.querySelector('.page-content.admin.edit.user, .page-content.admin.new.user');
|
|
const elLoginType = document.querySelector<HTMLInputElement>('#login_type');
|
|
if (!pageContent || !elLoginType) return;
|
|
const isNew = pageContent.classList.contains('new');
|
|
const elUserType = document.querySelector<HTMLInputElement>('#user_type');
|
|
const elUserName = document.querySelector<HTMLInputElement>('#user_name')!;
|
|
const elLoginName = document.querySelector<HTMLInputElement>('#login_name')!;
|
|
const elPassword = document.querySelector<HTMLInputElement>('#password')!;
|
|
|
|
const syncFields = (focusField: boolean) => {
|
|
const isBot = elUserType?.value === 'Bot';
|
|
const isLocal = !isBot && elLoginType.value.startsWith('0'); // login type 0 is a local account without auth source
|
|
toggleElem('.js-non-bot', !isBot);
|
|
if (!isBot) {
|
|
toggleElem('.js-local', isLocal);
|
|
toggleElem('.js-non-local', !isLocal);
|
|
}
|
|
elLoginName.toggleAttribute('required', !isBot && !isLocal);
|
|
if (isNew) {
|
|
elPassword.toggleAttribute('required', isLocal);
|
|
} else {
|
|
elUserName.toggleAttribute('disabled', !isBot && !isLocal);
|
|
}
|
|
if (focusField) (isBot || isLocal ? elUserName : elLoginName).focus();
|
|
};
|
|
|
|
elUserType?.addEventListener('change', () => syncFields(true));
|
|
elLoginType.addEventListener('change', () => syncFields(true));
|
|
if (isNew) syncFields(false);
|
|
}
|
|
|
|
function initAdminAuthentication() {
|
|
const pageContent = document.querySelector('.page-content.admin.authentication');
|
|
if (!pageContent) return;
|
|
|
|
const isNewPage = pageContent.classList.contains('new');
|
|
const isEditPage = pageContent.classList.contains('edit');
|
|
if (!isNewPage && !isEditPage) return;
|
|
|
|
function onUsePagedSearchChange() {
|
|
const searchPageSizeElements = document.querySelectorAll<HTMLDivElement>('.search-page-size');
|
|
if (document.querySelector<HTMLInputElement>('#use_paged_search')!.checked) {
|
|
showElem('.search-page-size');
|
|
for (const el of searchPageSizeElements) {
|
|
el.querySelector('input')?.setAttribute('required', 'required');
|
|
}
|
|
} else {
|
|
hideElem('.search-page-size');
|
|
for (const el of searchPageSizeElements) {
|
|
el.querySelector('input')?.removeAttribute('required');
|
|
}
|
|
}
|
|
}
|
|
|
|
function onOAuth2Change(applyDefaultValues: boolean) {
|
|
hideElem('.open_id_connect_auto_discovery_url, .open_id_connect_external_id_claim, .oauth2_use_custom_url');
|
|
for (const input of document.querySelectorAll<HTMLInputElement>('.open_id_connect_auto_discovery_url input[required]')) {
|
|
input.removeAttribute('required');
|
|
}
|
|
|
|
const provider = document.querySelector<HTMLInputElement>('#oauth2_provider')!.value;
|
|
switch (provider) {
|
|
case 'openidConnect':
|
|
case 'aws-cognito':
|
|
document.querySelector<HTMLInputElement>('.open_id_connect_auto_discovery_url input')!.setAttribute('required', 'required');
|
|
showElem('.open_id_connect_auto_discovery_url');
|
|
showElem('.open_id_connect_external_id_claim');
|
|
break;
|
|
default: {
|
|
const elProviderCustomUrlSettings = document.querySelector<HTMLInputElement>(`#${provider}_customURLSettings`);
|
|
if (!elProviderCustomUrlSettings) break; // some providers do not have custom URL settings
|
|
const couldChangeCustomURLs = elProviderCustomUrlSettings.getAttribute('data-available') === 'true';
|
|
const mustProvideCustomURLs = elProviderCustomUrlSettings.getAttribute('data-required') === 'true';
|
|
if (couldChangeCustomURLs) {
|
|
showElem('.oauth2_use_custom_url'); // show the checkbox
|
|
}
|
|
if (mustProvideCustomURLs) {
|
|
document.querySelector<HTMLInputElement>('#oauth2_use_custom_url')!.checked = true; // make the checkbox checked
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
const supportSshPublicKey = document.querySelector<HTMLInputElement>(`#${provider}_SupportSSHPublicKey`)?.value === 'true';
|
|
toggleElem('.field.oauth2_ssh_public_key_claim_name', supportSshPublicKey);
|
|
onOAuth2UseCustomURLChange(applyDefaultValues);
|
|
}
|
|
|
|
function onOAuth2UseCustomURLChange(applyDefaultValues: boolean) {
|
|
const provider = document.querySelector<HTMLInputElement>('#oauth2_provider')!.value;
|
|
hideElem('.oauth2_use_custom_url_field');
|
|
for (const input of document.querySelectorAll<HTMLInputElement>('.oauth2_use_custom_url_field input[required]')) {
|
|
input.removeAttribute('required');
|
|
}
|
|
|
|
const elProviderCustomUrlSettings = document.querySelector(`#${provider}_customURLSettings`);
|
|
if (elProviderCustomUrlSettings && document.querySelector<HTMLInputElement>('#oauth2_use_custom_url')!.checked) {
|
|
for (const custom of ['token_url', 'auth_url', 'profile_url', 'email_url', 'tenant']) {
|
|
if (applyDefaultValues) {
|
|
document.querySelector<HTMLInputElement>(`#oauth2_${custom}`)!.value = document.querySelector<HTMLInputElement>(`#${provider}_${custom}`)!.value;
|
|
}
|
|
const customInput = document.querySelector(`#${provider}_${custom}`);
|
|
if (customInput?.getAttribute('data-available') === 'true') {
|
|
for (const input of document.querySelectorAll(`.oauth2_${custom} input`)) {
|
|
input.setAttribute('required', 'required');
|
|
}
|
|
showElem(`.oauth2_${custom}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function onEnableLdapGroupsChange() {
|
|
const checked = document.querySelector<HTMLInputElement>('.js-ldap-group-toggle')?.checked;
|
|
toggleElem(document.querySelector('#ldap-group-options')!, checked);
|
|
}
|
|
|
|
const elAuthType = document.querySelector<HTMLInputElement>('#auth_type')!;
|
|
|
|
// New authentication
|
|
if (isNewPage) {
|
|
const onAuthTypeChange = function () {
|
|
hideElem('.ldap, .dldap, .smtp, .pam, .oauth2, .has-tls, .search-page-size, .sspi');
|
|
|
|
for (const input of document.querySelectorAll<HTMLInputElement>('.ldap input[required], .binddnrequired input[required], .dldap input[required], .smtp input[required], .pam input[required], .oauth2 input[required], .has-tls input[required], .sspi input[required]')) {
|
|
input.removeAttribute('required');
|
|
}
|
|
|
|
document.querySelector<HTMLDivElement>('.binddnrequired')?.classList.remove('required');
|
|
|
|
const authType = elAuthType.value;
|
|
switch (authType) {
|
|
case '2': // LDAP
|
|
showElem('.ldap');
|
|
for (const input of document.querySelectorAll<HTMLInputElement>('.binddnrequired input, .ldap div.required:not(.dldap) input')) {
|
|
input.setAttribute('required', 'required');
|
|
}
|
|
document.querySelector('.binddnrequired')?.classList.add('required');
|
|
break;
|
|
case '3': // SMTP
|
|
showElem('.smtp');
|
|
showElem('.has-tls');
|
|
for (const input of document.querySelectorAll<HTMLInputElement>('.smtp div.required input, .has-tls')) {
|
|
input.setAttribute('required', 'required');
|
|
}
|
|
break;
|
|
case '4': // PAM
|
|
showElem('.pam');
|
|
for (const input of document.querySelectorAll<HTMLInputElement>('.pam input')) {
|
|
input.setAttribute('required', 'required');
|
|
}
|
|
break;
|
|
case '5': // LDAP
|
|
showElem('.dldap');
|
|
for (const input of document.querySelectorAll<HTMLInputElement>('.dldap div.required:not(.ldap) input')) {
|
|
input.setAttribute('required', 'required');
|
|
}
|
|
break;
|
|
case '6': // OAuth2
|
|
showElem('.oauth2');
|
|
for (const input of document.querySelectorAll<HTMLInputElement>('.oauth2 div.required:not(.oauth2_use_custom_url,.oauth2_use_custom_url_field,.open_id_connect_auto_discovery_url) input')) {
|
|
input.setAttribute('required', 'required');
|
|
}
|
|
onOAuth2Change(true);
|
|
break;
|
|
case '7': // SSPI
|
|
showElem('.sspi');
|
|
for (const input of document.querySelectorAll<HTMLInputElement>('.sspi div.required input')) {
|
|
input.setAttribute('required', 'required');
|
|
}
|
|
break;
|
|
}
|
|
if (authType === '2' || authType === '5') {
|
|
onSecurityProtocolChange();
|
|
onEnableLdapGroupsChange();
|
|
}
|
|
if (authType === '2') {
|
|
onUsePagedSearchChange();
|
|
}
|
|
};
|
|
elAuthType.addEventListener('change', onAuthTypeChange);
|
|
onAuthTypeChange();
|
|
|
|
document.querySelector<HTMLInputElement>('#security_protocol')?.addEventListener('change', onSecurityProtocolChange);
|
|
document.querySelector<HTMLInputElement>('#use_paged_search')?.addEventListener('change', onUsePagedSearchChange);
|
|
document.querySelector<HTMLInputElement>('#oauth2_provider')?.addEventListener('change', () => onOAuth2Change(true));
|
|
document.querySelector<HTMLInputElement>('#oauth2_use_custom_url')?.addEventListener('change', () => onOAuth2UseCustomURLChange(true));
|
|
|
|
document.querySelector('.js-ldap-group-toggle')!.addEventListener('change', onEnableLdapGroupsChange);
|
|
}
|
|
// Edit authentication
|
|
if (isEditPage) {
|
|
const authType = elAuthType.value;
|
|
if (authType === '2' || authType === '5') {
|
|
document.querySelector<HTMLInputElement>('#security_protocol')?.addEventListener('change', onSecurityProtocolChange);
|
|
document.querySelector('.js-ldap-group-toggle')!.addEventListener('change', onEnableLdapGroupsChange);
|
|
onEnableLdapGroupsChange();
|
|
if (authType === '2') {
|
|
document.querySelector<HTMLInputElement>('#use_paged_search')?.addEventListener('change', onUsePagedSearchChange);
|
|
}
|
|
} else if (authType === '6') {
|
|
document.querySelector<HTMLInputElement>('#oauth2_provider')?.addEventListener('change', () => onOAuth2Change(true));
|
|
document.querySelector<HTMLInputElement>('#oauth2_use_custom_url')?.addEventListener('change', () => onOAuth2UseCustomURLChange(false));
|
|
onOAuth2Change(false);
|
|
}
|
|
}
|
|
|
|
const elAuthName = document.querySelector<HTMLInputElement>('#auth_name')!;
|
|
const onAuthNameChange = function () {
|
|
// appSubUrl is either empty or is a path that starts with `/` and doesn't have a trailing slash.
|
|
document.querySelector('#oauth2-callback-url')!.textContent = `${window.location.origin}${appSubUrl}/user/oauth2/${pathEscape(elAuthName.value)}/callback`;
|
|
};
|
|
elAuthName.addEventListener('input', onAuthNameChange);
|
|
onAuthNameChange();
|
|
}
|
|
|
|
function initAdminNotice() {
|
|
const pageContent = document.querySelector('.page-content.admin.notice');
|
|
if (!pageContent) return;
|
|
|
|
const detailModal = document.querySelector<HTMLDivElement>('#detail-modal')!;
|
|
|
|
// Attach view detail modals
|
|
queryElems(pageContent, '.view-detail', (el) => el.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
const elNoticeDesc = el.closest('tr')!.querySelector('.notice-description')!;
|
|
const elModalDesc = detailModal.querySelector('.content pre')!;
|
|
elModalDesc.textContent = elNoticeDesc.textContent;
|
|
showFomanticModal(detailModal);
|
|
}));
|
|
|
|
// Select actions
|
|
const checkboxes = document.querySelectorAll<HTMLInputElement>('.select.table .ui.checkbox input');
|
|
|
|
queryElems(pageContent, '.select.action', (el) => el.addEventListener('click', () => {
|
|
switch (el.getAttribute('data-action')) {
|
|
case 'select-all':
|
|
for (const checkbox of checkboxes) {
|
|
checkbox.checked = true;
|
|
}
|
|
break;
|
|
case 'deselect-all':
|
|
for (const checkbox of checkboxes) {
|
|
checkbox.checked = false;
|
|
}
|
|
break;
|
|
case 'inverse':
|
|
for (const checkbox of checkboxes) {
|
|
checkbox.checked = !checkbox.checked;
|
|
}
|
|
break;
|
|
}
|
|
}));
|
|
|
|
document.querySelector<HTMLButtonElement>('#delete-selection')?.addEventListener('click', async function (e) {
|
|
e.preventDefault();
|
|
this.classList.add('is-loading', 'disabled');
|
|
const data = new FormData();
|
|
for (const checkbox of checkboxes) {
|
|
if (checkbox.checked) {
|
|
data.append('ids[]', checkbox.closest('.ui.checkbox')!.getAttribute('data-id')!);
|
|
}
|
|
}
|
|
await POST(this.getAttribute('data-link')!, {data});
|
|
window.location.reload();
|
|
});
|
|
}
|