diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/broker/events/AmazonQViewType.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/broker/events/AmazonQViewType.java index 7ccfb74a5..3622d9b22 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/broker/events/AmazonQViewType.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/broker/events/AmazonQViewType.java @@ -6,6 +6,6 @@ public enum AmazonQViewType { TOOLKIT_LOGIN_VIEW, CHAT_VIEW, DEPENDENCY_MISSING_VIEW, RE_AUTHENTICATE_VIEW, CHAT_ASSET_MISSING_VIEW, - LSP_STARTUP_FAILED_VIEW + LSP_STARTUP_FAILED_VIEW, Q_DEV_ACCESS_BLOCKED_VIEW } diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/broker/events/QDevAccessBlockedState.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/broker/events/QDevAccessBlockedState.java new file mode 100644 index 000000000..f144d1697 --- /dev/null +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/broker/events/QDevAccessBlockedState.java @@ -0,0 +1,15 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.broker.events; + +/** + * Whether Amazon Q Developer has refused this identity at sign-in. + * + *
Reacting to the refusal signs the user out, which on its own would route to the ordinary login + * view and lose the explanation. This state is therefore resolved ahead of the logged-out state by + * {@code ViewRouter}, so the user lands on a screen that says what happened. + */ +public enum QDevAccessBlockedState { + NOT_BLOCKED, BLOCKED +} diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/broker/events/ViewRouterPluginState.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/broker/events/ViewRouterPluginState.java index 9a5e077fe..f20230eba 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/broker/events/ViewRouterPluginState.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/broker/events/ViewRouterPluginState.java @@ -7,5 +7,5 @@ public record ViewRouterPluginState(AuthState authState, AmazonQLspState lspState, BrowserCompatibilityState browserCompatibilityState, ChatWebViewAssetState chatWebViewAssetState, ToolkitLoginWebViewAssetState toolkitLoginWebViewAssetState, - QDeveloperProfileState qDeveloperProfileState) { + QDeveloperProfileState qDeveloperProfileState, QDevAccessBlockedState qDevAccessBlockedState) { } diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspClient.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspClient.java index 54ae81373..3770567af 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspClient.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspClient.java @@ -16,6 +16,7 @@ import software.aws.toolkits.eclipse.amazonq.lsp.auth.model.SsoTokenChangedParams; import software.aws.toolkits.eclipse.amazonq.lsp.model.ConnectionMetadata; import software.aws.toolkits.eclipse.amazonq.lsp.model.OpenFileDiffParams; +import software.aws.toolkits.eclipse.amazonq.lsp.model.ShowNotificationParams; public interface AmazonQLspClient extends LanguageClient { @@ -61,6 +62,9 @@ public interface AmazonQLspClient extends LanguageClient { @JsonNotification("aws/didCreateDirectory") void didCreateDirectory(Object params); + @JsonNotification("aws/window/showNotification") + void showNotification(ShowNotificationParams params); + @JsonNotification("aws/chat/sendPinnedContext") void sendPinnedContext(Object params); } diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspClientImpl.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspClientImpl.java index dbeda359d..70dec828d 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspClientImpl.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspClientImpl.java @@ -82,6 +82,8 @@ import software.aws.toolkits.eclipse.amazonq.lsp.auth.model.SsoTokenChangedParams; import software.aws.toolkits.eclipse.amazonq.lsp.model.ConnectionMetadata; import software.aws.toolkits.eclipse.amazonq.lsp.model.OpenFileDiffParams; +import software.aws.toolkits.eclipse.amazonq.lsp.model.ShowNotificationParams; +import software.aws.toolkits.eclipse.amazonq.broker.events.QDevAccessBlockedState; import software.aws.toolkits.eclipse.amazonq.lsp.model.OpenTabUiResponse; import software.aws.toolkits.eclipse.amazonq.lsp.model.SsoProfileData; import software.aws.toolkits.eclipse.amazonq.lsp.model.TelemetryEvent; @@ -257,6 +259,37 @@ public final void ssoTokenChanged(final SsoTokenChangedParams params) { } } + /** + * Handles the language server's generic notification channel. Today the only notification this + * plugin acts on is the report that Amazon Q Developer has refused this identity at sign-in. + * + *
Reacting means signing the user out and routing to an explanation. Sign-out happens here + * rather than in the view because the session is already useless: every Q request from this + * identity is refused, so leaving the user signed in would show a working-looking IDE that + * silently does nothing. + * + *
Never throws. This runs on the LSP message thread and is shared by every future
+ * notification, so a failure to classify one must not take the channel down.
+ */
+ @Override
+ public final void showNotification(final ShowNotificationParams params) {
+ try {
+ if (!QDevAccessBlockedNotification.isAccessBlocked(params)) {
+ return;
+ }
+
+ String message = params.content() == null ? null : params.content().text();
+ Activator.getLogger().info("Amazon Q Developer access is blocked for this identity: " + message);
+
+ // Publish before signing out. Sign-out makes the router re-evaluate, and the blocked
+ // state has to be in place by then or the router resolves the ordinary login view.
+ Activator.getEventBroker().post(QDevAccessBlockedState.class, QDevAccessBlockedState.BLOCKED);
+ Activator.getLoginService().logout();
+ } catch (Exception e) {
+ Activator.getLogger().error("Failed to handle showNotification", e);
+ }
+ }
+
@Override
public final void sendContextCommands(final Object params) {
var command = ChatUIInboundCommand.createCommand("aws/chat/sendContextCommands", params);
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspServerBuilder.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspServerBuilder.java
index 6b657c856..d43fdced6 100644
--- a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspServerBuilder.java
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspServerBuilder.java
@@ -63,6 +63,9 @@ private Map Only the language server ever observes the refusal. The service gates on the User-Agent of the
+ * shared language server, so the plugin's own SDK calls are allowed unconditionally -- there is no
+ * client-side signal to classify. The server reports it over the existing notification channel and
+ * this class decides whether a given notification is that report.
+ *
+ * Identification is by id, never by title. The runtime's router rewrites the declared id into
+ * base64 of {@code {"serverName":...,"id":...}}, so the raw id never reaches us and a title match
+ * would sign out a working user the first time an unrelated error reused the same title.
+ */
+public final class QDevAccessBlockedNotification {
+
+ /** Id declared by the server for this notification, found inside the routed envelope. */
+ private static final String BLOCKED_NOTIFICATION_ID = "qDevPluginAccessBlocked";
+
+ private static final ObjectMapper OBJECT_MAPPER = ObjectMapperFactory.getInstance();
+
+ private QDevAccessBlockedNotification() {
+ // utility class
+ }
+
+ /**
+ * @return true when the given notification reports that this identity is blocked from Amazon Q
+ * Developer. Never throws: an unrecognised or malformed notification is simply not a
+ * match, because failing to classify one must not break the notification channel for
+ * every other message that uses it.
+ */
+ public static boolean isAccessBlocked(final ShowNotificationParams params) {
+ if (params == null) {
+ return false;
+ }
+ return BLOCKED_NOTIFICATION_ID.equals(resolveId(params.id()));
+ }
+
+ /**
+ * Resolves the id the server declared. Accepts the routed form, base64 of
+ * {@code {"serverName":...,"id":...}}, and falls back to the raw value so that a server or
+ * runtime that does not wrap the id still matches.
+ */
+ private static String resolveId(final String id) {
+ if (id == null || id.isBlank()) {
+ return null;
+ }
+ try {
+ String decoded = new String(Base64.getDecoder().decode(id), StandardCharsets.UTF_8);
+ JsonNode node = OBJECT_MAPPER.readTree(decoded);
+ JsonNode inner = node.get("id");
+ if (inner != null && inner.isTextual()) {
+ return inner.asText();
+ }
+ } catch (Exception e) {
+ // Not a routed envelope. Fall through and treat the value as a plain id.
+ }
+ return id;
+ }
+}
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/model/ShowNotificationParams.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/model/ShowNotificationParams.java
new file mode 100644
index 000000000..8b914de95
--- /dev/null
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/model/ShowNotificationParams.java
@@ -0,0 +1,24 @@
+// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.eclipse.amazonq.lsp.model;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+/**
+ * Parameters of the {@code aws/window/showNotification} notification, the language server's generic
+ * channel for surfacing a message to the user.
+ *
+ * The {@code id} does not arrive as the server declared it. The runtime's router rewrites it into
+ * base64 of {@code {"serverName":...,"id":...}} so that a follow-up action can be routed back to the
+ * server that raised it. Callers must therefore decode the envelope and compare the inner id rather
+ * than this field, and must never key behaviour off {@code content.title} -- titles are shared
+ * between unrelated notifications, so matching on one would fire this handler for the wrong message.
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public record ShowNotificationParams(String id, String type, NotificationContent content) {
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record NotificationContent(String title, String text) {
+ }
+}
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/views/AmazonQViewContainer.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/views/AmazonQViewContainer.java
index 427402201..22d5bbfbd 100644
--- a/plugin/src/software/aws/toolkits/eclipse/amazonq/views/AmazonQViewContainer.java
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/views/AmazonQViewContainer.java
@@ -35,7 +35,8 @@ AmazonQViewType.DEPENDENCY_MISSING_VIEW, new DependencyMissingView(),
AmazonQViewType.RE_AUTHENTICATE_VIEW, new ReauthenticateView(),
AmazonQViewType.LSP_STARTUP_FAILED_VIEW, new LspStartUpFailedView(),
AmazonQViewType.CHAT_VIEW, new AmazonQChatWebview(),
- AmazonQViewType.TOOLKIT_LOGIN_VIEW, new ToolkitLoginWebview());
+ AmazonQViewType.TOOLKIT_LOGIN_VIEW, new ToolkitLoginWebview(),
+ AmazonQViewType.Q_DEV_ACCESS_BLOCKED_VIEW, new QDevAccessBlockedView());
}
public AmazonQViewContainer() {
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/views/QDevAccessBlockedView.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/views/QDevAccessBlockedView.java
new file mode 100644
index 000000000..60d5ff552
--- /dev/null
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/views/QDevAccessBlockedView.java
@@ -0,0 +1,99 @@
+// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.eclipse.amazonq.views;
+
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.events.SelectionAdapter;
+import org.eclipse.swt.events.SelectionEvent;
+import org.eclipse.swt.events.SelectionListener;
+import org.eclipse.swt.layout.GridData;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Link;
+
+import software.aws.toolkits.eclipse.amazonq.broker.events.QDevAccessBlockedState;
+import software.aws.toolkits.eclipse.amazonq.plugin.Activator;
+import software.aws.toolkits.eclipse.amazonq.util.PluginUtils;
+
+/**
+ * Shown when Amazon Q Developer refuses this identity at sign-in.
+ *
+ * Amazon Q Developer stopped accepting new Builder ID accounts. Such an account signs in
+ * successfully -- sign-in is OIDC and is never gated -- and then finds Q silently non-functional,
+ * with the service's refusal arriving as if it were a chat reply. This view replaces that dead end
+ * with an explanation, a pointer to Kiro, and a route back to sign-in for anyone whose Builder ID
+ * predates the cutoff.
+ *
+ * The dates and URLs below are product copy taken from the public announcement, not values
+ * reported by the service. The service's own message is deliberately not displayed: it is a single
+ * sentence written for an API consumer, and it does not say what the user should do next.
+ */
+public final class QDevAccessBlockedView extends CallToActionView {
+
+ public static final String ID = "software.aws.toolkits.eclipse.amazonq.views.QDevAccessBlockedView";
+
+ private static final String ICON_PATH = "icons/AmazonQ64.png";
+ private static final String HEADER_LABEL = "New sign-ups are no longer available";
+ private static final String SIGNUP_CUTOFF_DATE = "May 15, 2026";
+ private static final String END_OF_SUPPORT_DATE = "April 30, 2027";
+ private static final String DETAIL_MESSAGE = "Amazon Q Developer stopped accepting new accounts as of "
+ + SIGNUP_CUTOFF_DATE + ". Amazon Q Developer IDE plugins are reaching end of support on "
+ + END_OF_SUPPORT_DATE + "."
+ + System.lineSeparator() + System.lineSeparator()
+ + "Kiro includes all the AI coding features from Q Developer, plus spec-driven development and more."
+ + System.lineSeparator() + System.lineSeparator()
+ + "If your Builder ID was created before " + SIGNUP_CUTOFF_DATE
+ + ", you can still sign in -- only newly created accounts are blocked.";
+ private static final String BUTTON_LABEL = "Get started with Kiro";
+ private static final String LINK_LABEL = "Try a different login method";
+
+ private static final String KIRO_URL = "https://kiro.dev";
+
+ @Override
+ protected String getIconPath() {
+ return ICON_PATH;
+ }
+
+ @Override
+ protected String getHeaderLabel() {
+ return HEADER_LABEL;
+ }
+
+ @Override
+ protected String getDetailMessage() {
+ return DETAIL_MESSAGE;
+ }
+
+ @Override
+ protected String getButtonLabel() {
+ return BUTTON_LABEL;
+ }
+
+ @Override
+ protected SelectionListener getButtonHandler() {
+ return new SelectionAdapter() {
+ @Override
+ public void widgetSelected(final SelectionEvent e) {
+ PluginUtils.openWebpage(KIRO_URL);
+ }
+ };
+ }
+
+ @Override
+ protected void setupButtonFooterContent(final Composite composite) {
+ Link hyperlink = new Link(composite, SWT.NONE);
+ hyperlink.setText("" + LINK_LABEL + "");
+ hyperlink.setLayoutData(new GridData(SWT.CENTER, SWT.CENTER, true, false));
+ hyperlink.addSelectionListener(new SelectionAdapter() {
+ @Override
+ public void widgetSelected(final SelectionEvent e) {
+ /*
+ * Clearing the state is what returns the user to sign-in: reacting to the refusal
+ * already signed them out, so the router resolves the logged-out state as soon as
+ * this view stops taking priority. Signing out again here would be a no-op.
+ */
+ Activator.getEventBroker().post(QDevAccessBlockedState.class, QDevAccessBlockedState.NOT_BLOCKED);
+ }
+ });
+ }
+}
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/views/router/ViewRouter.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/views/router/ViewRouter.java
index 372f6a8c8..23221e277 100644
--- a/plugin/src/software/aws/toolkits/eclipse/amazonq/views/router/ViewRouter.java
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/views/router/ViewRouter.java
@@ -10,6 +10,7 @@
import software.aws.toolkits.eclipse.amazonq.broker.events.AmazonQViewType;
import software.aws.toolkits.eclipse.amazonq.broker.events.BrowserCompatibilityState;
import software.aws.toolkits.eclipse.amazonq.broker.events.ChatWebViewAssetState;
+import software.aws.toolkits.eclipse.amazonq.broker.events.QDevAccessBlockedState;
import software.aws.toolkits.eclipse.amazonq.broker.events.QDeveloperProfileState;
import software.aws.toolkits.eclipse.amazonq.broker.events.ToolkitLoginWebViewAssetState;
import software.aws.toolkits.eclipse.amazonq.broker.events.ViewRouterPluginState;
@@ -69,6 +70,11 @@ private ViewRouter(final Builder builder) {
builder.qDeveloperProfileStateObservable = Activator.getEventBroker()
.ofObservable(QDeveloperProfileState.class);
}
+
+ if (builder.qDevAccessBlockedStateObservable == null) {
+ builder.qDevAccessBlockedStateObservable = Activator.getEventBroker()
+ .ofObservable(QDevAccessBlockedState.class);
+ }
/**
* Combines all state observables into a single stream that emits a new PluginState
* whenever any individual state changes. The combined stream:
@@ -78,7 +84,7 @@ private ViewRouter(final Builder builder) {
Observable.combineLatest(builder.authStateObservable, builder.lspStateObservable,
builder.browserCompatibilityStateObservable, builder.chatWebViewAssetStateObservable,
builder.toolkitLoginWebViewAssetStateObservable, builder.qDeveloperProfileStateObservable,
- ViewRouterPluginState::new).observeOn(Schedulers.computation()).subscribe(this::onEvent);
+ builder.qDevAccessBlockedStateObservable, ViewRouterPluginState::new).observeOn(Schedulers.computation()).subscribe(this::onEvent);
}
public static Builder builder() {
@@ -117,6 +123,13 @@ private void refreshActiveView(final ViewRouterPluginState pluginState) {
} else if (pluginState.chatWebViewAssetState() == ChatWebViewAssetState.DEPENDENCY_MISSING
|| pluginState.toolkitLoginWebViewAssetState() == ToolkitLoginWebViewAssetState.DEPENDENCY_MISSING) {
newActiveView = AmazonQViewType.CHAT_ASSET_MISSING_VIEW;
+ } else if (pluginState.qDevAccessBlockedState() == QDevAccessBlockedState.BLOCKED) {
+ /*
+ * Resolved ahead of the logged-out state on purpose. Reacting to the refusal signs the
+ * user out, so this state and the logged-out state are always true together; checking
+ * logged out first would show the ordinary login view and lose the explanation.
+ */
+ newActiveView = AmazonQViewType.Q_DEV_ACCESS_BLOCKED_VIEW;
} else if (pluginState.authState().isLoggedOut()) {
newActiveView = AmazonQViewType.TOOLKIT_LOGIN_VIEW;
} else if (pluginState.authState().isExpired()) {
@@ -160,6 +173,7 @@ public static final class Builder {
private Observable