fix: misc
parent
ba1ed1252c
commit
526dd8347d
|
@ -46,16 +46,13 @@ func NewSQLProvider(config *schema.Configuration, name, driverName, dataSourceNa
|
||||||
sqlUpdateTOTPConfigRecordSignIn: fmt.Sprintf(queryFmtUpdateTOTPConfigRecordSignIn, tableTOTPConfigurations),
|
sqlUpdateTOTPConfigRecordSignIn: fmt.Sprintf(queryFmtUpdateTOTPConfigRecordSignIn, tableTOTPConfigurations),
|
||||||
sqlUpdateTOTPConfigRecordSignInByUsername: fmt.Sprintf(queryFmtUpdateTOTPConfigRecordSignInByUsername, tableTOTPConfigurations),
|
sqlUpdateTOTPConfigRecordSignInByUsername: fmt.Sprintf(queryFmtUpdateTOTPConfigRecordSignInByUsername, tableTOTPConfigurations),
|
||||||
|
|
||||||
sqlUpsertWebauthnDevice: fmt.Sprintf(queryFmtUpsertWebauthnDevice, tableWebauthnDevices),
|
sqlInsertWebauthnDevice: fmt.Sprintf(queryFmtUpsertInsertDevice, tableWebauthnDevices),
|
||||||
sqlSelectWebauthnDevices: fmt.Sprintf(queryFmtSelectWebauthnDevices, tableWebauthnDevices),
|
sqlSelectWebauthnDevices: fmt.Sprintf(queryFmtSelectWebauthnDevices, tableWebauthnDevices),
|
||||||
sqlSelectWebauthnDevicesByUsername: fmt.Sprintf(queryFmtSelectWebauthnDevicesByUsername, tableWebauthnDevices),
|
sqlSelectWebauthnDevicesByUsername: fmt.Sprintf(queryFmtSelectWebauthnDevicesByUsername, tableWebauthnDevices),
|
||||||
sqlSelectWebauthnDevicesByRPIDByUsername: fmt.Sprintf(queryFmtSelectWebauthnDevicesByRPIDByUsername, tableWebauthnDevices),
|
sqlSelectWebauthnDevicesByRPIDByUsername: fmt.Sprintf(queryFmtSelectWebauthnDevicesByRPIDByUsername, tableWebauthnDevices),
|
||||||
sqlSelectWebauthnDeviceByID: fmt.Sprintf(queryFmtSelectWebauthnDeviceByID, tableWebauthnDevices),
|
sqlSelectWebauthnDeviceByID: fmt.Sprintf(queryFmtSelectWebauthnDeviceByID, tableWebauthnDevices),
|
||||||
sqlUpdateWebauthnDeviceDescriptionByUsernameAndID: fmt.Sprintf(queryFmtUpdateUpdateWebauthnDeviceDescriptionByUsernameAndID, tableWebauthnDevices),
|
sqlUpdateWebauthnDeviceDescriptionByUsernameAndID: fmt.Sprintf(queryFmtUpdateUpdateWebauthnDeviceDescriptionByUsernameAndID, tableWebauthnDevices),
|
||||||
sqlUpdateWebauthnDevicePublicKey: fmt.Sprintf(queryFmtUpdateWebauthnDevicePublicKey, tableWebauthnDevices),
|
|
||||||
sqlUpdateWebauthnDevicePublicKeyByUsername: fmt.Sprintf(queryFmtUpdateWebauthnDevicePublicKeyByUsername, tableWebauthnDevices),
|
|
||||||
sqlUpdateWebauthnDeviceRecordSignIn: fmt.Sprintf(queryFmtUpdateWebauthnDeviceRecordSignIn, tableWebauthnDevices),
|
sqlUpdateWebauthnDeviceRecordSignIn: fmt.Sprintf(queryFmtUpdateWebauthnDeviceRecordSignIn, tableWebauthnDevices),
|
||||||
sqlUpdateWebauthnDeviceRecordSignInByUsername: fmt.Sprintf(queryFmtUpdateWebauthnDeviceRecordSignInByUsername, tableWebauthnDevices),
|
|
||||||
sqlDeleteWebauthnDevice: fmt.Sprintf(queryFmtDeleteWebauthnDevice, tableWebauthnDevices),
|
sqlDeleteWebauthnDevice: fmt.Sprintf(queryFmtDeleteWebauthnDevice, tableWebauthnDevices),
|
||||||
sqlDeleteWebauthnDeviceByUsername: fmt.Sprintf(queryFmtDeleteWebauthnDeviceByUsername, tableWebauthnDevices),
|
sqlDeleteWebauthnDeviceByUsername: fmt.Sprintf(queryFmtDeleteWebauthnDeviceByUsername, tableWebauthnDevices),
|
||||||
sqlDeleteWebauthnDeviceByUsernameAndDescription: fmt.Sprintf(queryFmtDeleteWebauthnDeviceByUsernameAndDescription, tableWebauthnDevices),
|
sqlDeleteWebauthnDeviceByUsernameAndDescription: fmt.Sprintf(queryFmtDeleteWebauthnDeviceByUsernameAndDescription, tableWebauthnDevices),
|
||||||
|
@ -164,17 +161,14 @@ type SQLProvider struct {
|
||||||
sqlUpdateTOTPConfigRecordSignInByUsername string
|
sqlUpdateTOTPConfigRecordSignInByUsername string
|
||||||
|
|
||||||
// Table: webauthn_devices.
|
// Table: webauthn_devices.
|
||||||
sqlUpsertWebauthnDevice string
|
sqlInsertWebauthnDevice string
|
||||||
sqlSelectWebauthnDevices string
|
sqlSelectWebauthnDevices string
|
||||||
sqlSelectWebauthnDevicesByUsername string
|
sqlSelectWebauthnDevicesByUsername string
|
||||||
sqlSelectWebauthnDevicesByRPIDByUsername string
|
sqlSelectWebauthnDevicesByRPIDByUsername string
|
||||||
sqlSelectWebauthnDeviceByID string
|
sqlSelectWebauthnDeviceByID string
|
||||||
|
|
||||||
sqlUpdateWebauthnDeviceDescriptionByUsernameAndID string
|
sqlUpdateWebauthnDeviceDescriptionByUsernameAndID string
|
||||||
sqlUpdateWebauthnDevicePublicKey string
|
|
||||||
sqlUpdateWebauthnDevicePublicKeyByUsername string
|
|
||||||
sqlUpdateWebauthnDeviceRecordSignIn string
|
sqlUpdateWebauthnDeviceRecordSignIn string
|
||||||
sqlUpdateWebauthnDeviceRecordSignInByUsername string
|
|
||||||
|
|
||||||
sqlDeleteWebauthnDevice string
|
sqlDeleteWebauthnDevice string
|
||||||
sqlDeleteWebauthnDeviceByUsername string
|
sqlDeleteWebauthnDeviceByUsername string
|
||||||
|
@ -847,7 +841,7 @@ func (p *SQLProvider) SaveWebauthnDevice(ctx context.Context, device model.Webau
|
||||||
return fmt.Errorf("error encrypting the Webauthn device public key for user '%s' kid '%x': %w", device.Username, device.KID, err)
|
return fmt.Errorf("error encrypting the Webauthn device public key for user '%s' kid '%x': %w", device.Username, device.KID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err = p.db.ExecContext(ctx, p.sqlUpsertWebauthnDevice,
|
if _, err = p.db.ExecContext(ctx, p.sqlInsertWebauthnDevice,
|
||||||
device.CreatedAt, device.LastUsedAt,
|
device.CreatedAt, device.LastUsedAt,
|
||||||
device.RPID, device.Username, device.Description,
|
device.RPID, device.Username, device.Description,
|
||||||
device.KID, device.PublicKey,
|
device.KID, device.PublicKey,
|
||||||
|
|
|
@ -31,7 +31,6 @@ func NewPostgreSQLProvider(config *schema.Configuration, caCertPool *x509.CertPo
|
||||||
|
|
||||||
// Specific alterations to this provider.
|
// Specific alterations to this provider.
|
||||||
// PostgreSQL doesn't have a UPSERT statement but has an ON CONFLICT operation instead.
|
// PostgreSQL doesn't have a UPSERT statement but has an ON CONFLICT operation instead.
|
||||||
provider.sqlUpsertWebauthnDevice = fmt.Sprintf(queryFmtUpsertWebauthnDevicePostgreSQL, tableWebauthnDevices)
|
|
||||||
provider.sqlUpsertDuoDevice = fmt.Sprintf(queryFmtUpsertDuoDevicePostgreSQL, tableDuoDevices)
|
provider.sqlUpsertDuoDevice = fmt.Sprintf(queryFmtUpsertDuoDevicePostgreSQL, tableDuoDevices)
|
||||||
provider.sqlUpsertTOTPConfig = fmt.Sprintf(queryFmtUpsertTOTPConfigurationPostgreSQL, tableTOTPConfigurations)
|
provider.sqlUpsertTOTPConfig = fmt.Sprintf(queryFmtUpsertTOTPConfigurationPostgreSQL, tableTOTPConfigurations)
|
||||||
provider.sqlUpsertPreferred2FAMethod = fmt.Sprintf(queryFmtUpsertPreferred2FAMethodPostgreSQL, tableUserPreferences)
|
provider.sqlUpsertPreferred2FAMethod = fmt.Sprintf(queryFmtUpsertPreferred2FAMethodPostgreSQL, tableUserPreferences)
|
||||||
|
@ -59,14 +58,13 @@ func NewPostgreSQLProvider(config *schema.Configuration, caCertPool *x509.CertPo
|
||||||
provider.sqlDeleteTOTPConfig = provider.db.Rebind(provider.sqlDeleteTOTPConfig)
|
provider.sqlDeleteTOTPConfig = provider.db.Rebind(provider.sqlDeleteTOTPConfig)
|
||||||
provider.sqlSelectTOTPConfigs = provider.db.Rebind(provider.sqlSelectTOTPConfigs)
|
provider.sqlSelectTOTPConfigs = provider.db.Rebind(provider.sqlSelectTOTPConfigs)
|
||||||
|
|
||||||
|
provider.sqlInsertWebauthnDevice = provider.db.Rebind(provider.sqlInsertWebauthnDevice)
|
||||||
provider.sqlSelectWebauthnDevices = provider.db.Rebind(provider.sqlSelectWebauthnDevices)
|
provider.sqlSelectWebauthnDevices = provider.db.Rebind(provider.sqlSelectWebauthnDevices)
|
||||||
provider.sqlSelectWebauthnDevicesByUsername = provider.db.Rebind(provider.sqlSelectWebauthnDevicesByUsername)
|
provider.sqlSelectWebauthnDevicesByUsername = provider.db.Rebind(provider.sqlSelectWebauthnDevicesByUsername)
|
||||||
|
provider.sqlSelectWebauthnDevicesByRPIDByUsername = provider.db.Rebind(provider.sqlSelectWebauthnDevicesByRPIDByUsername)
|
||||||
provider.sqlSelectWebauthnDeviceByID = provider.db.Rebind(provider.sqlSelectWebauthnDeviceByID)
|
provider.sqlSelectWebauthnDeviceByID = provider.db.Rebind(provider.sqlSelectWebauthnDeviceByID)
|
||||||
provider.sqlUpdateWebauthnDeviceDescriptionByUsernameAndID = provider.db.Rebind(provider.sqlUpdateWebauthnDeviceDescriptionByUsernameAndID)
|
provider.sqlUpdateWebauthnDeviceDescriptionByUsernameAndID = provider.db.Rebind(provider.sqlUpdateWebauthnDeviceDescriptionByUsernameAndID)
|
||||||
provider.sqlUpdateWebauthnDevicePublicKey = provider.db.Rebind(provider.sqlUpdateWebauthnDevicePublicKey)
|
|
||||||
provider.sqlUpdateWebauthnDevicePublicKeyByUsername = provider.db.Rebind(provider.sqlUpdateWebauthnDevicePublicKeyByUsername)
|
|
||||||
provider.sqlUpdateWebauthnDeviceRecordSignIn = provider.db.Rebind(provider.sqlUpdateWebauthnDeviceRecordSignIn)
|
provider.sqlUpdateWebauthnDeviceRecordSignIn = provider.db.Rebind(provider.sqlUpdateWebauthnDeviceRecordSignIn)
|
||||||
provider.sqlUpdateWebauthnDeviceRecordSignInByUsername = provider.db.Rebind(provider.sqlUpdateWebauthnDeviceRecordSignInByUsername)
|
|
||||||
provider.sqlDeleteWebauthnDevice = provider.db.Rebind(provider.sqlDeleteWebauthnDevice)
|
provider.sqlDeleteWebauthnDevice = provider.db.Rebind(provider.sqlDeleteWebauthnDevice)
|
||||||
provider.sqlDeleteWebauthnDeviceByUsername = provider.db.Rebind(provider.sqlDeleteWebauthnDeviceByUsername)
|
provider.sqlDeleteWebauthnDeviceByUsername = provider.db.Rebind(provider.sqlDeleteWebauthnDeviceByUsername)
|
||||||
provider.sqlDeleteWebauthnDeviceByUsernameAndDescription = provider.db.Rebind(provider.sqlDeleteWebauthnDeviceByUsernameAndDescription)
|
provider.sqlDeleteWebauthnDeviceByUsernameAndDescription = provider.db.Rebind(provider.sqlDeleteWebauthnDeviceByUsernameAndDescription)
|
||||||
|
|
|
@ -149,11 +149,6 @@ const (
|
||||||
SET public_key = ?
|
SET public_key = ?
|
||||||
WHERE id = ?;`
|
WHERE id = ?;`
|
||||||
|
|
||||||
queryFmtUpdateWebauthnDevicePublicKeyByUsername = `
|
|
||||||
UPDATE %s
|
|
||||||
SET public_key = ?
|
|
||||||
WHERE username = ? AND kid = ?;`
|
|
||||||
|
|
||||||
queryFmtUpdateUpdateWebauthnDeviceDescriptionByUsernameAndID = `
|
queryFmtUpdateUpdateWebauthnDeviceDescriptionByUsernameAndID = `
|
||||||
UPDATE %s
|
UPDATE %s
|
||||||
SET description = ?
|
SET description = ?
|
||||||
|
@ -166,22 +161,9 @@ const (
|
||||||
clone_warning = CASE clone_warning WHEN TRUE THEN TRUE ELSE ? END
|
clone_warning = CASE clone_warning WHEN TRUE THEN TRUE ELSE ? END
|
||||||
WHERE id = ?;`
|
WHERE id = ?;`
|
||||||
|
|
||||||
queryFmtUpdateWebauthnDeviceRecordSignInByUsername = `
|
queryFmtUpsertInsertDevice = `
|
||||||
UPDATE %s
|
|
||||||
SET
|
|
||||||
rpid = ?, last_used_at = ?, sign_count = ?,
|
|
||||||
clone_warning = CASE clone_warning WHEN TRUE THEN TRUE ELSE ? END
|
|
||||||
WHERE username = ? AND kid = ?;`
|
|
||||||
|
|
||||||
queryFmtUpsertWebauthnDevice = `
|
|
||||||
REPLACE INTO %s (created_at, last_used_at, rpid, username, description, kid, public_key, attestation_type, transport, aaguid, sign_count, clone_warning)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`
|
|
||||||
|
|
||||||
queryFmtUpsertWebauthnDevicePostgreSQL = `
|
|
||||||
INSERT INTO %s (created_at, last_used_at, rpid, username, description, kid, public_key, attestation_type, transport, aaguid, sign_count, clone_warning)
|
INSERT INTO %s (created_at, last_used_at, rpid, username, description, kid, public_key, attestation_type, transport, aaguid, sign_count, clone_warning)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`
|
||||||
ON CONFLICT (rpid, username, description)
|
|
||||||
DO UPDATE SET created_at = $1, last_used_at = $2, kid = $6, public_key = $7, attestation_type = $8, transport = $9, aaguid = $10, sign_count = $11, clone_warning = $12;`
|
|
||||||
|
|
||||||
queryFmtDeleteWebauthnDevice = `
|
queryFmtDeleteWebauthnDevice = `
|
||||||
DELETE FROM %s
|
DELETE FROM %s
|
||||||
|
|
|
@ -8,6 +8,7 @@ export async function PostWithOptionalResponse<T = undefined>(path: string, body
|
||||||
if (res.status !== 200 || hasServiceError(res).errored) {
|
if (res.status !== 200 || hasServiceError(res).errored) {
|
||||||
throw new Error(`Failed POST to ${path}. Code: ${res.status}. Message: ${hasServiceError(res).message}`);
|
throw new Error(`Failed POST to ${path}. Code: ${res.status}. Message: ${hasServiceError(res).message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return toData<T>(res);
|
return toData<T>(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -32,3 +33,21 @@ export async function Get<T = undefined>(path: string): Promise<T> {
|
||||||
}
|
}
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function GetWithOptionalData<T = undefined>(path: string): Promise<T | null> {
|
||||||
|
const res = await axios.get<ServiceResponse<T>>(path);
|
||||||
|
|
||||||
|
if (res.status !== 200 || hasServiceError(res).errored) {
|
||||||
|
throw new Error(`Failed GET from ${path}. Code: ${res.status}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const d = toData<T>(res);
|
||||||
|
if (d === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!d) {
|
||||||
|
throw new Error("unexpected type of response");
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { WebauthnDevice } from "@models/Webauthn";
|
import { WebauthnDevice } from "@models/Webauthn";
|
||||||
import { WebauthnDevicesPath } from "@services/Api";
|
import { WebauthnDevicesPath } from "@services/Api";
|
||||||
import { Get } from "@services/Client";
|
import { GetWithOptionalData } from "@services/Client";
|
||||||
|
|
||||||
// getWebauthnDevices returns the list of webauthn devices for the authenticated user.
|
// getWebauthnDevices returns the list of webauthn devices for the authenticated user.
|
||||||
export async function getWebauthnDevices(): Promise<WebauthnDevice[]> {
|
export async function getWebauthnDevices(): Promise<WebauthnDevice[] | null> {
|
||||||
return Get<WebauthnDevice[]>(WebauthnDevicesPath);
|
return GetWithOptionalData<WebauthnDevice[] | null>(WebauthnDevicesPath);
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,10 +4,9 @@ import { Fingerprint } from "@mui/icons-material";
|
||||||
import DeleteIcon from "@mui/icons-material/Delete";
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
import EditIcon from "@mui/icons-material/Edit";
|
import EditIcon from "@mui/icons-material/Edit";
|
||||||
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
||||||
import { Box, Button, Paper, Stack, Tooltip, Typography } from "@mui/material";
|
import { Box, Button, CircularProgress, Paper, Stack, Tooltip, Typography } from "@mui/material";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import LoadingButton from "@components/LoadingButton";
|
|
||||||
import { useNotifications } from "@hooks/NotificationsContext";
|
import { useNotifications } from "@hooks/NotificationsContext";
|
||||||
import { WebauthnDevice } from "@models/Webauthn";
|
import { WebauthnDevice } from "@models/Webauthn";
|
||||||
import { deleteDevice, updateDevice } from "@services/Webauthn";
|
import { deleteDevice, updateDevice } from "@services/Webauthn";
|
||||||
|
@ -169,26 +168,26 @@ export default function WebauthnDeviceItem(props: Props) {
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title={translate("Edit information for this Webauthn credential")}>
|
<Tooltip title={translate("Edit information for this Webauthn credential")}>
|
||||||
<LoadingButton
|
<Button
|
||||||
loading={loadingEdit}
|
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="primary"
|
color="primary"
|
||||||
startIcon={<EditIcon />}
|
startIcon={loadingEdit ? <CircularProgress color="inherit" size={20} /> : <EditIcon />}
|
||||||
onClick={() => setShowDialogEdit(true)}
|
onClick={loadingEdit ? undefined : () => setShowDialogEdit(true)}
|
||||||
>
|
>
|
||||||
{translate("Edit")}
|
{translate("Edit")}
|
||||||
</LoadingButton>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title={translate("Remove this Webauthn credential")}>
|
<Tooltip title={translate("Remove this Webauthn credential")}>
|
||||||
<LoadingButton
|
<Button
|
||||||
loading={loadingDelete}
|
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="secondary"
|
color="primary"
|
||||||
startIcon={<DeleteIcon />}
|
startIcon={
|
||||||
onClick={() => setShowDialogDelete(true)}
|
loadingDelete ? <CircularProgress color="inherit" size={20} /> : <DeleteIcon />
|
||||||
|
}
|
||||||
|
onClick={loadingDelete ? undefined : () => setShowDialogDelete(true)}
|
||||||
>
|
>
|
||||||
{translate("Remove")}
|
{translate("Remove")}
|
||||||
</LoadingButton>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
@ -64,11 +64,11 @@ const WebauthnDeviceRegisterDialog = function (props: Props) {
|
||||||
setName("");
|
setName("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = useCallback(() => {
|
||||||
resetStates();
|
resetStates();
|
||||||
|
|
||||||
props.setCancelled();
|
props.setCancelled();
|
||||||
};
|
}, [props]);
|
||||||
|
|
||||||
const finishAttestation = async () => {
|
const finishAttestation = async () => {
|
||||||
if (!result || !result.response) {
|
if (!result || !result.response) {
|
||||||
|
|
|
@ -15,11 +15,11 @@ interface Props {
|
||||||
export default function WebauthnDevicesStack(props: Props) {
|
export default function WebauthnDevicesStack(props: Props) {
|
||||||
const { t: translate } = useTranslation("settings");
|
const { t: translate } = useTranslation("settings");
|
||||||
|
|
||||||
const [devices, setDevices] = useState<WebauthnDevice[]>([]);
|
const [devices, setDevices] = useState<WebauthnDevice[] | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async function () {
|
(async function () {
|
||||||
setDevices([]);
|
setDevices(null);
|
||||||
const devices = await getWebauthnDevices();
|
const devices = await getWebauthnDevices();
|
||||||
setDevices(devices);
|
setDevices(devices);
|
||||||
})();
|
})();
|
||||||
|
@ -27,7 +27,7 @@ export default function WebauthnDevicesStack(props: Props) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
{devices.length !== 0 ? (
|
{devices !== null && devices.length !== 0 ? (
|
||||||
<Stack spacing={3}>
|
<Stack spacing={3}>
|
||||||
{devices.map((x, idx) => (
|
{devices.map((x, idx) => (
|
||||||
<WebauthnDeviceItem key={idx} index={idx} device={x} handleEdit={props.incrementRefreshState} />
|
<WebauthnDeviceItem key={idx} index={idx} device={x} handleEdit={props.incrementRefreshState} />
|
||||||
|
|
Loading…
Reference in New Issue