001/*
002 * JGrapes Event Driven Framework
003 * Copyright (C) 2022 Michael N. Lipp
004 * 
005 * This program is free software; you can redistribute it and/or modify it 
006 * under the terms of the GNU Affero General Public License as published by 
007 * the Free Software Foundation; either version 3 of the License, or 
008 * (at your option) any later version.
009 * 
010 * This program is distributed in the hope that it will be useful, but 
011 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
012 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License 
013 * for more details.
014 * 
015 * You should have received a copy of the GNU Affero General Public License along 
016 * with this program; if not, see <http://www.gnu.org/licenses/>.
017 */
018
019package org.jgrapes.webconlet.examples.login;
020
021import freemarker.core.ParseException;
022import freemarker.template.MalformedTemplateNameException;
023import freemarker.template.Template;
024import freemarker.template.TemplateException;
025import freemarker.template.TemplateNotFoundException;
026import java.beans.ConstructorProperties;
027import java.io.IOException;
028import java.io.StringWriter;
029import java.util.HashSet;
030import java.util.Map;
031import java.util.Optional;
032import java.util.Set;
033import java.util.concurrent.Future;
034import javax.security.auth.Subject;
035import org.jgrapes.core.Channel;
036import org.jgrapes.core.Components;
037import org.jgrapes.core.Event;
038import org.jgrapes.core.Manager;
039import org.jgrapes.core.annotation.Handler;
040import org.jgrapes.http.events.DiscardSession;
041import org.jgrapes.io.events.Close;
042import org.jgrapes.webconsole.base.Conlet.RenderMode;
043import org.jgrapes.webconsole.base.ConletBaseModel;
044import org.jgrapes.webconsole.base.ConsoleConnection;
045import org.jgrapes.webconsole.base.ConsoleUser;
046import org.jgrapes.webconsole.base.WebConsoleUtils;
047import org.jgrapes.webconsole.base.events.AddConletRequest;
048import org.jgrapes.webconsole.base.events.AddConletType;
049import org.jgrapes.webconsole.base.events.AddPageResources.ScriptResource;
050import org.jgrapes.webconsole.base.events.CloseModalDialog;
051import org.jgrapes.webconsole.base.events.ConsolePrepared;
052import org.jgrapes.webconsole.base.events.ConsoleReady;
053import org.jgrapes.webconsole.base.events.NotifyConletModel;
054import org.jgrapes.webconsole.base.events.NotifyConletView;
055import org.jgrapes.webconsole.base.events.OpenModalDialog;
056import org.jgrapes.webconsole.base.events.RenderConlet;
057import org.jgrapes.webconsole.base.events.RenderConletRequestBase;
058import org.jgrapes.webconsole.base.events.SetLocale;
059import org.jgrapes.webconsole.base.events.SimpleConsoleCommand;
060import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet;
061
062/**
063 * A conlet for poll administration.
064 */
065@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
066public class LoginConlet extends FreeMarkerConlet<LoginConlet.AccountModel> {
067
068    private static final String PENDING_CONSOLE_PREPARED
069        = "pendingCosolePrepared";
070
071    /**
072     * Creates a new component with its channel set to the given channel.
073     * 
074     * @param componentChannel the channel that the component's handlers listen
075     *            on by default and that {@link Manager#fire(Event, Channel...)}
076     *            sends the event to
077     */
078    public LoginConlet(Channel componentChannel) {
079        super(componentChannel);
080    }
081
082    @Override
083    protected String generateInstanceId(AddConletRequest event,
084            ConsoleConnection session) {
085        return "Singleton";
086    }
087
088    /**
089     * Register conlet.
090     *
091     * @param event the event
092     * @param channel the channel
093     * @throws TemplateNotFoundException the template not found exception
094     * @throws MalformedTemplateNameException the malformed template name exception
095     * @throws ParseException the parse exception
096     * @throws IOException Signals that an I/O exception has occurred.
097     */
098    @Handler
099    public void onConsoleReady(ConsoleReady event, ConsoleConnection channel)
100            throws TemplateNotFoundException, MalformedTemplateNameException,
101            ParseException, IOException {
102        // Add conlet resources to page
103        channel.respond(new AddConletType(type())
104            .addScript(new ScriptResource()
105                .setScriptUri(event.renderSupport().conletResource(
106                    type(), "Login-functions.js"))
107                .setScriptType("module"))
108            .addCss(event.renderSupport(), WebConsoleUtils.uriFromPath(
109                "Login-style.css"))
110            .addPageContent("headerIcons", Map.of("priority", "1000"))
111            .addRenderMode(RenderMode.Content));
112    }
113
114    /**
115     * As a model has already been created in {@link #doUpdateConletState},
116     * the "new" model may already exist in the session.
117     */
118    @Override
119    protected Optional<AccountModel> createNewState(AddConletRequest event,
120            ConsoleConnection session, String conletId) throws Exception {
121        Optional<AccountModel> model
122            = stateFromSession(session.session(), conletId);
123        if (model.isPresent()) {
124            return model;
125        }
126        return super.createNewState(event, session, conletId);
127    }
128
129    @Override
130    protected Optional<AccountModel> createStateRepresentation(
131            RenderConletRequestBase<?> event,
132            ConsoleConnection channel, String conletId) throws IOException {
133        return Optional.of(new AccountModel(conletId));
134    }
135
136    /**
137     * Handle web console page loaded.
138     *
139     * @param event the event
140     * @param channel the channel
141     * @throws IOException 
142     * @throws ParseException 
143     * @throws MalformedTemplateNameException 
144     * @throws TemplateNotFoundException 
145     */
146    @Handler(priority = 1000)
147    public void onConsolePrepared(ConsolePrepared event,
148            ConsoleConnection channel)
149            throws TemplateNotFoundException, MalformedTemplateNameException,
150            ParseException, IOException {
151        // If we are logged in, proceed
152        if (channel.session().containsKey(Subject.class)) {
153            return;
154        }
155
156        // Suspend handling and save event "in" channel.
157        event.suspendHandling();
158        channel.setAssociated(PENDING_CONSOLE_PREPARED, event);
159
160        // Create model and save in session.
161        String conletId = type() + TYPE_INSTANCE_SEPARATOR + "Singleton";
162        AccountModel accountModel = new AccountModel(conletId);
163        accountModel.setDialogOpen(true);
164        putInSession(channel.session(), conletId, accountModel);
165
166        // Render login dialog
167        Template tpl = freemarkerConfig().getTemplate("Login-dialog.ftl.html");
168        var bundle = resourceBundle(channel.locale());
169        channel.respond(new OpenModalDialog(type(), conletId,
170            processTemplate(event, tpl,
171                fmSessionModel(channel.session())))
172                    .addOption("title", bundle.getString("title"))
173                    .addOption("cancelable", false).addOption("okayLabel", "")
174                    .addOption("applyLabel", bundle.getString("Submit"))
175                    .addOption("useSubmit", true));
176    }
177
178    private Future<String> processTemplate(
179            Event<?> request, Template template,
180            Object dataModel) {
181        return request.processedBy().map(procBy -> procBy.executorService())
182            .orElse(Components.defaultExecutorService()).submit(() -> {
183                StringWriter out = new StringWriter();
184                try {
185                    template.process(dataModel, out);
186                } catch (TemplateException | IOException e) {
187                    throw new IllegalArgumentException(e);
188                }
189                return out.toString();
190
191            });
192    }
193
194    @Override
195    protected Set<RenderMode> doRenderConlet(RenderConletRequestBase<?> event,
196            ConsoleConnection channel, String conletId,
197            AccountModel model) throws Exception {
198        Set<RenderMode> renderedAs = new HashSet<>();
199        if (event.renderAs().contains(RenderMode.Content)) {
200            Template tpl
201                = freemarkerConfig().getTemplate("Login-status.ftl.html");
202            channel.respond(new RenderConlet(type(), conletId,
203                processTemplate(event, tpl,
204                    fmModel(event, channel, conletId, model)))
205                        .setRenderAs(RenderMode.Content));
206            channel.respond(new NotifyConletView(type(), conletId,
207                "updateUser",
208                WebConsoleUtils.userFromSession(channel.session())
209                    .map(ConsoleUser::getDisplayName).orElse(null)));
210            renderedAs.add(RenderMode.Content);
211        }
212        return renderedAs;
213    }
214
215    @Override
216    protected void doUpdateConletState(NotifyConletModel event,
217            ConsoleConnection connection, AccountModel model) throws Exception {
218        var bundle = resourceBundle(connection.locale());
219        if ("loginData".equals(event.method())) {
220            String userName = event.params().asString(0);
221            if (userName == null || userName.isEmpty()) {
222                connection.respond(new NotifyConletView(type(),
223                    model.getConletId(), "setMessages",
224                    null, bundle.getString("emptyUserName")));
225                return;
226            }
227            model.setDialogOpen(false);
228            Subject user = new Subject();
229            user.getPrincipals().add(new ConsoleUser(userName, userName));
230            connection.session().put(Subject.class, user);
231            connection.respond(new CloseModalDialog(type(), event.conletId()));
232            connection
233                .associated(PENDING_CONSOLE_PREPARED, ConsolePrepared.class)
234                .ifPresentOrElse(ConsolePrepared::resumeHandling,
235                    () -> connection
236                        .respond(new SimpleConsoleCommand("reload")));
237            return;
238        }
239        if ("logout".equals(event.method())) {
240            connection.responsePipeline()
241                .fire(new Close(), connection.upstreamChannel()).get();
242            connection.close();
243            connection.respond(new DiscardSession(connection.session(),
244                connection.webletChannel()));
245            // Alternative to sending Close (see above):
246            // channel.respond(new SimpleConsoleCommand("reload"));
247        }
248    }
249
250    @Override
251    protected boolean doSetLocale(SetLocale event, ConsoleConnection channel,
252            String conletId) throws Exception {
253        return stateFromSession(channel.session(),
254            type() + TYPE_INSTANCE_SEPARATOR + "Singleton")
255                .map(model -> !model.isDialogOpen()).orElse(true);
256    }
257
258    /**
259     * Model with account info.
260     */
261    public static class AccountModel extends ConletBaseModel {
262
263        private boolean dialogOpen;
264
265        /**
266         * Creates a new model with the given type and id.
267         * 
268         * @param conletId the web console component id
269         */
270        @ConstructorProperties({ "conletId" })
271        public AccountModel(String conletId) {
272            super(conletId);
273        }
274
275        /**
276         * Checks if is dialog open.
277         *
278         * @return true, if is dialog open
279         */
280        public boolean isDialogOpen() {
281            return dialogOpen;
282        }
283
284        /**
285         * Sets the dialog open.
286         *
287         * @param dialogOpen the new dialog open
288         */
289        public void setDialogOpen(boolean dialogOpen) {
290            this.dialogOpen = dialogOpen;
291        }
292
293    }
294
295}