001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.petstore.console.users; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020 021import org.apache.juneau.commons.beanquery.*; 022import org.apache.juneau.http.*; 023import org.apache.juneau.http.response.*; 024import org.apache.juneau.marshall.collections.*; 025import org.apache.juneau.petstore.console.*; 026import org.apache.juneau.petstore.console.pages.*; 027import org.apache.juneau.petstore.dto.*; 028import org.apache.juneau.rest.server.*; 029import org.apache.juneau.console.datatables.*; 030import org.apache.juneau.console.datatables.adapter.*; 031import org.apache.juneau.rest.server.view.*; 032import org.apache.juneau.rest.server.view.freemarker.*; 033import org.apache.juneau.console.views.*; 034import org.apache.juneau.console.widgets.*; 035 036/** 037 * The Users page and its endpoints. The password is write-only: every read goes through {@link UserView}, which has 038 * no password, so it never leaves the server; an update with no password keeps the stored one. 039 * 040 * <h5 class='section'>Example:</h5> 041 * <p class='bjava'> 042 * <jc>// The wire shape of a create (JSON5): the password is accepted but never echoed.</jc> 043 * { username:<js>'newbie'</js>, firstName:<js>'New'</js>, lastName:<js>'Bie'</js>, password:<js>'s3cret'</js> } 044 * </p> 045 * <p class='bcode'> 046 * <@card type="datatables" id="users"> 047 * { dataMode:'server', dataUrl:'/console/users/query', columns:[{key:'username', label:'Username'}, ...] } 048 * </@card> 049 * </p> 050 */ 051@Rest(path="/users", title="Users") 052@SuppressWarnings({ 053 "java:S110", // Inheritance depth comes from the BasicRestServlet hierarchy, not this page. 054 "java:S1192" // Route paths and field names repeat as inline literals by design. 055}) 056public class UsersRest extends PetstoreConsolePage { 057 058 private static final long serialVersionUID = 1L; 059 060 /** The query context over {@link UserView}, so the password is neither searchable nor serialized. */ 061 private static final InMemoryBeanQueryContext<UserView> VIEW_QUERY = InMemoryBeanQueryContext.create(UserView.class).build(); 062 063 /** 064 * A user as the console shows it: everything but the password. 065 * 066 * <h5 class='section'>Example:</h5> 067 * <p class='bcode'> 068 * { username:'mwatson', firstName:'Mary', lastName:'Watson', userStatus:'ACTIVE' } 069 * </p> 070 * 071 * @param username The username (primary key). 072 * @param firstName The first name. 073 * @param lastName The last name. 074 * @param userStatus The account status. 075 */ 076 public record UserView(String username, String firstName, String lastName, UserStatus userStatus) { 077 078 /** 079 * @param u The stored user. 080 * @return Its password-free view. 081 */ 082 public static UserView of(User u) { 083 return new UserView(u.getUsername(), u.getFirstName(), u.getLastName(), u.getUserStatus()); 084 } 085 } 086 087 /** 088 * The Users page. 089 * 090 * @param req The request; {@code ?builder} renders the builder twin. 091 * @return The page. 092 */ 093 @RestGet(path="/") 094 public View page(RestRequest req) { 095 return builder(req) ? twin(req, UsersPageBuilders.users()) : FreemarkerView.of("users.ftlh"); 096 } 097 098 /** 099 * The add/edit dialog form. The password field is write-only: it is never pre-filled. 100 * 101 * @param username The user to edit, or <jk>null</jk> to add. 102 * @return The modal. 103 * @throws NotFound If {@code username} names no user. 104 */ 105 @RestGet(path="/form") 106 public ModalDef form(@Query("username") String username) { 107 var u = username == null ? new User() : require(username); 108 var form = FormDef.create(); 109 if (username == null) 110 form.field(FormDef.Input.of("username", "Username", "text").required()); 111 form.field(FormDef.Input.of("firstName", "First name", "text").value(u.getFirstName())); 112 form.field(FormDef.Input.of("lastName", "Last name", "text").value(u.getLastName())); 113 var password = FormDef.Input.of("password", "Password", "password"); 114 form.field(username == null ? password.required().help("Write-only; it is never shown again") : password.help("Leave blank to keep the current password")); 115 return ModalDef.create(username == null ? "Add user" : f("Edit '%s'", username)).form(form); 116 } 117 118 /** 119 * Server-mode DataTables query over all users, as password-free views. 120 * 121 * @param req The DataTables request. 122 * @return One page of users, or a 200 {@code error} envelope for a malformed search expression. 123 */ 124 @RestPost(path="/query") 125 public DataTablesResults<UserView> query(@Content DataTablesRequest req) { 126 try (var s = VIEW_QUERY.getSession(store().getUsers().stream().map(UserView::of).toList())) { 127 return DataTablesQuery.run(req, s); 128 } catch (BeanQuerySyntaxException e) { 129 return DataTablesResults.error(req.getDraw(), e.getMessage()); 130 } 131 } 132 133 /** 134 * @param username The username. 135 * @return The user, without the password. 136 * @throws NotFound If it does not exist. 137 */ 138 @RestGet(path="/{username}") 139 public UserView get(@Path("username") String username) { 140 return UserView.of(require(username)); 141 } 142 143 /** 144 * Creates a user. 145 * 146 * @param req The request (write gate, audit actor). 147 * @param body {@code {username, firstName, lastName, password}}, either plain or as the dialog envelope {@code {fields:{...}}}. 148 * @return The result toast, carrying the user without the password. 149 * @throws BadRequest On a blank username. 150 * @throws Conflict If the username is taken. 151 */ 152 @RestPost(path="/") 153 public ActionResult create(RestRequest req, @Content JsonMap body) { 154 var actor = ConsoleWrites.actor(req); 155 var f = fields(body); 156 var username = f.getString("username"); 157 if (ib(username)) 158 throw new BadRequest("Username must not be blank"); 159 if (store().getUser(username) != null) 160 throw new Conflict("User '%s' already exists", username); 161 var u = new User().setUsername(username).setFirstName(f.getString("firstName")).setLastName(f.getString("lastName")) 162 .setPassword(f.getString("password")).setUserStatus(status(f, UserStatus.ACTIVE)); 163 store().createUser(u, actor); 164 return ActionResult.success(UserView.of(u)).message(f("Added '%s'", username)); 165 } 166 167 /** 168 * Updates a user. A field left out of the body, and a null or blank password, keep their stored values. 169 * 170 * @param req The request. 171 * @param username The username. 172 * @param body The new state, either plain or as the dialog envelope {@code {fields:{...}}}. 173 * @return The result toast, carrying the user without the password. 174 * @throws NotFound If the user does not exist. 175 */ 176 @RestPut(path="/{username}") 177 public ActionResult update(RestRequest req, @Path("username") String username, @Content JsonMap body) { 178 var actor = ConsoleWrites.actor(req); 179 var old = require(username); 180 var f = fields(body); 181 var pw = f.getString("password"); 182 var u = new User().setUsername(username) 183 .setFirstName(f.containsKey("firstName") ? f.getString("firstName") : old.getFirstName()) 184 .setLastName(f.containsKey("lastName") ? f.getString("lastName") : old.getLastName()) 185 .setEmail(old.getEmail()).setPhone(old.getPhone()) 186 .setUserStatus(status(f, old.getUserStatus())) 187 .setPassword(pw == null || pw.isBlank() ? old.getPassword() : pw); 188 store().updateUser(u, actor); 189 return ActionResult.success(UserView.of(u)).message(f("Saved '%s'", username)); 190 } 191 192 /** 193 * Deletes a user. 194 * 195 * @param req The request. 196 * @param username The username. 197 * @return The result toast. 198 * @throws NotFound If the user does not exist. 199 */ 200 @RestDelete(path="/{username}") 201 public ActionResult delete(RestRequest req, @Path("username") String username) { 202 var actor = ConsoleWrites.actor(req); 203 require(username); 204 store().deleteUser(username, actor); 205 return ActionResult.success(null).message(f("Deleted '%s'", username)); 206 } 207 208 private User require(String username) { 209 var u = store().getUser(username); 210 if (u == null) 211 throw new NotFound("Unknown user '%s'", username); 212 return u; 213 } 214 215 /** Unwraps the dialog envelope ({@code fields}) if present; otherwise the body is the form itself. */ 216 private static JsonMap fields(JsonMap body) { 217 if (body == null) 218 return new JsonMap(); 219 var f = body.getMap("fields"); 220 return f != null ? f : body; 221 } 222 223 private static UserStatus status(JsonMap f, UserStatus dflt) { 224 var s = f.getString("userStatus"); 225 if (ib(s)) 226 return dflt; 227 try { 228 return UserStatus.valueOf(s); 229 } catch (IllegalArgumentException e) { 230 throw new BadRequest("Unknown user status '%s'", s); 231 } 232 } 233}